From ae9f798bb35fcf48f9fcbe169a0c304cb4bf3fd5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 25 Mar 2026 19:52:27 +0100 Subject: [PATCH 01/44] feat(node-cli): generate intent-first commands --- packages/node/README.md | 20 +- packages/node/package.json | 9 +- .../node/scripts/generate-intent-commands.ts | 457 +++++ packages/node/src/Transloadit.ts | 6 +- packages/node/src/cli/commands/assemblies.ts | 10 +- .../src/cli/commands/generated-intents.ts | 1778 +++++++++++++++++ packages/node/src/cli/commands/index.ts | 6 + packages/node/src/cli/intentCommandSpecs.ts | 652 ++++++ packages/node/src/cli/intentRuntime.ts | 52 + packages/node/test/unit/cli/intents.test.ts | 235 +++ 10 files changed, 3214 insertions(+), 11 deletions(-) create mode 100644 packages/node/scripts/generate-intent-commands.ts create mode 100644 packages/node/src/cli/commands/generated-intents.ts create mode 100644 packages/node/src/cli/intentCommandSpecs.ts create mode 100644 packages/node/src/cli/intentRuntime.ts create mode 100644 packages/node/test/unit/cli/intents.test.ts diff --git a/packages/node/README.md b/packages/node/README.md index 1540e3f3..8d84defb 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -84,7 +84,25 @@ npx -y transloadit auth token --aud mcp --scope assemblies:write,templates:read ### Processing Media -Create Assemblies to process files using Assembly Instructions (steps) or Templates: +For common one-off tasks, prefer the intent-first commands: + +```bash +# Generate an image from a text prompt +npx transloadit image generate --prompt "A red bicycle in a studio" --out bicycle.png + +# Generate a preview for a remote file URL +npx transloadit preview generate --input https://example.com/file.pdf --out preview.png + +# Encode a video into an HLS package +npx transloadit video encode-hls --input input.mp4 --out dist/hls +``` + +The generated intent catalog also includes commands such as `image remove-background`, +`image optimize`, `image resize`, `document convert`, `document optimize`, +`document auto-rotate`, `document thumbs`, `audio waveform`, `text speak`, +`video thumbs`, `file compress`, and `file decompress`. + +For full control, create Assemblies directly using Assembly Instructions (steps) or Templates: ```bash # Process a file using a steps file diff --git a/packages/node/package.json b/packages/node/package.json index b2da1b2c..846b6045 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -82,16 +82,17 @@ "src": "./src" }, "scripts": { - "check": "yarn lint:ts && yarn fix && yarn test:unit", + "sync:intents": "node scripts/generate-intent-commands.ts", + "check": "yarn sync:intents && yarn lint:ts && yarn fix && yarn test:unit", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", "lint:js": "biome check .", - "lint": "npm-run-all --parallel 'lint:js'", - "fix": "npm-run-all --serial 'fix:js'", + "lint": "yarn lint:js", + "fix": "yarn fix:js", "lint:deps": "knip --dependencies --no-progress", "fix:deps": "knip --dependencies --no-progress --fix", - "prepack": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && rm -f tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo && yarn --cwd ../.. tsc:node", + "prepack": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && rm -f tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo && yarn sync:intents && yarn --cwd ../.. tsc:node", "test:unit": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage ./test/unit", "test:e2e": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run ./test/e2e", "test": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage" diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts new file mode 100644 index 00000000..50f53455 --- /dev/null +++ b/packages/node/scripts/generate-intent-commands.ts @@ -0,0 +1,457 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import type { ZodObject } from 'zod' +import { + ZodBoolean, + ZodDefault, + ZodEffects, + ZodEnum, + ZodLiteral, + ZodNullable, + ZodNumber, + ZodOptional, + ZodString, + ZodUnion, +} from 'zod' + +import type { + IntentCommandSpec, + IntentInputLocalFilesSpec, + IntentSchemaOptionSpec, +} from '../src/cli/intentCommandSpecs.ts' +import { intentCommandSpecs } from '../src/cli/intentCommandSpecs.ts' + +type GeneratedFieldKind = 'boolean' | 'number' | 'string' + +interface GeneratedSchemaField { + description?: string + kind: GeneratedFieldKind + name: string + optionFlags: string + propertyName: string + required: boolean +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const packageRoot = path.resolve(__dirname, '..') +const outputPath = path.resolve(__dirname, '../src/cli/commands/generated-intents.ts') + +function toCamelCase(value: string): string { + return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()) +} + +function toKebabCase(value: string): string { + return value.replaceAll('_', '-') +} + +function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { + let schema = input + let required = true + + while (true) { + if (schema instanceof ZodOptional) { + required = false + schema = schema.unwrap() + continue + } + + if (schema instanceof ZodDefault) { + required = false + schema = schema.removeDefault() + continue + } + + if (schema instanceof ZodNullable) { + required = false + schema = schema.unwrap() + continue + } + + return { required, schema } + } +} + +function collectSchemaFields(schemaOptions: IntentSchemaOptionSpec): GeneratedSchemaField[] { + const shape = (schemaOptions.schema as ZodObject>).shape + const requiredKeys = new Set(schemaOptions.requiredKeys ?? []) + + return schemaOptions.keys.map((key) => { + const fieldSchema = shape[key] + if (fieldSchema == null) { + throw new Error(`Schema is missing expected key "${key}"`) + } + + const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) + const propertyName = toCamelCase(key) + const optionFlags = `--${toKebabCase(key)}` + const description = fieldSchema.description + const required = requiredKeys.has(key) || schemaRequired + + if (unwrappedSchema instanceof ZodString || unwrappedSchema instanceof ZodEnum) { + return { name: key, propertyName, optionFlags, required, description, kind: 'string' } + } + + if (unwrappedSchema instanceof ZodNumber) { + return { name: key, propertyName, optionFlags, required, description, kind: 'number' } + } + + if (unwrappedSchema instanceof ZodBoolean) { + return { name: key, propertyName, optionFlags, required, description, kind: 'boolean' } + } + + if (unwrappedSchema instanceof ZodEffects) { + const effectInnerSchema = unwrappedSchema._def.schema + const kind: GeneratedFieldKind = effectInnerSchema instanceof ZodNumber ? 'number' : 'string' + return { name: key, propertyName, optionFlags, required, description, kind } + } + + if (unwrappedSchema instanceof ZodLiteral) { + return { name: key, propertyName, optionFlags, required, description, kind: 'string' } + } + + if (unwrappedSchema instanceof ZodUnion) { + return { name: key, propertyName, optionFlags, required, description, kind: 'string' } + } + + throw new Error(`Unsupported schema type for "${key}"`) + }) +} + +function formatDescription(description: string | undefined): string { + return JSON.stringify((description ?? '').trim()) +} + +function formatUsageExamples(examples: Array<[string, string]>): string { + return examples + .map(([label, example]) => ` [${JSON.stringify(label)}, ${JSON.stringify(example)}],`) + .join('\n') +} + +function formatSchemaFields(fieldSpecs: GeneratedSchemaField[]): string { + return fieldSpecs + .map((fieldSpec) => { + const requiredLine = fieldSpec.required ? '\n required: true,' : '' + return ` ${fieldSpec.propertyName} = Option.String('${fieldSpec.optionFlags}', { + description: ${formatDescription(fieldSpec.description)},${requiredLine} + })` + }) + .join('\n\n') +} + +function formatRawValues(fieldSpecs: GeneratedSchemaField[]): string { + if (fieldSpecs.length === 0) { + return '{}' + } + + return `{ +${fieldSpecs.map((fieldSpec) => ` ${JSON.stringify(fieldSpec.name)}: this.${fieldSpec.propertyName},`).join('\n')} + }` +} + +function formatFieldSpecsLiteral(fieldSpecs: GeneratedSchemaField[]): string { + if (fieldSpecs.length === 0) return '[]' + + return `[ +${fieldSpecs + .map( + (fieldSpec) => + ` { name: ${JSON.stringify(fieldSpec.name)}, kind: ${JSON.stringify(fieldSpec.kind)} },`, + ) + .join('\n')} + ]` +} + +function formatLocalInputOptions(input: IntentInputLocalFilesSpec): string { + const blocks = [ + ` inputs = Option.Array('--input,-i', { + description: ${JSON.stringify(input.description)}, + })`, + ] + + if (input.recursive !== false) { + blocks.push(` recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + })`) + } + + if (input.allowWatch) { + blocks.push(` watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + })`) + } + + if (input.deleteAfterProcessing !== false) { + blocks.push(` deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + })`) + } + + if (input.reprocessStale !== false) { + blocks.push(` reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + })`) + } + + if (input.allowSingleAssembly) { + blocks.push(` singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + })`) + } + + if (input.allowConcurrency) { + blocks.push(` concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + })`) + } + + return blocks.join('\n\n') +} + +function formatInputOptions(spec: IntentCommandSpec): string { + if (spec.input.kind === 'local-files') { + return formatLocalInputOptions(spec.input) + } + + if (spec.input.kind === 'remote-url') { + return ` input = Option.String('--input,-i', { + description: ${JSON.stringify(spec.input.description)}, + required: true, + })` + } + + return '' +} + +function formatLocalCreateOptions(input: IntentInputLocalFilesSpec): string { + const entries = [' inputs: this.inputs ?? [],', ' output: this.outputPath,'] + + if (input.recursive !== false) { + entries.push(' recursive: this.recursive,') + } + + if (input.allowWatch) { + entries.push(' watch: this.watch,') + } + + if (input.deleteAfterProcessing !== false) { + entries.push(' del: this.deleteAfterProcessing,') + } + + if (input.reprocessStale !== false) { + entries.push(' reprocessStale: this.reprocessStale,') + } + + if (input.allowSingleAssembly) { + entries.push(' singleAssembly: this.singleAssembly,') + } else if (input.defaultSingleAssembly) { + entries.push(' singleAssembly: true,') + } + + if (input.allowConcurrency) { + entries.push( + ' concurrency: this.concurrency == null ? undefined : Number(this.concurrency),', + ) + } + + return entries.join('\n') +} + +function formatLocalValidation(input: IntentInputLocalFilesSpec, commandLabel: string): string { + const lines = [ + ' if ((this.inputs ?? []).length === 0) {', + ` this.output.error('${commandLabel} requires at least one --input')`, + ' return 1', + ' }', + ] + + if (input.allowWatch && input.allowSingleAssembly) { + lines.push( + '', + ' if (this.singleAssembly && this.watch) {', + " this.output.error('--single-assembly cannot be used with --watch')", + ' return 1', + ' }', + ) + } + + if (input.allowWatch && input.defaultSingleAssembly) { + lines.push( + '', + ' if (this.watch) {', + " this.output.error('--watch is not supported for this command')", + ' return 1', + ' }', + ) + } + + return lines.join('\n') +} + +function formatRunBody(spec: IntentCommandSpec, fieldSpecs: GeneratedSchemaField[]): string { + if (spec.execution.kind === 'single-step') { + const parseStep = ` const step = parseIntentStep({ + schema: ${spec.schemaOptions?.importName}, + fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ')}, + fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, + rawValues: ${formatRawValues(fieldSpecs)}, + })` + + if (spec.input.kind === 'local-files') { + return `${formatLocalValidation(spec.input, spec.paths[0].join(' '))} + +${parseStep} + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + ${JSON.stringify(spec.execution.resultStepName)}: step, + }, +${formatLocalCreateOptions(spec.input)} + }) + + return hasFailures ? 1 : undefined` + } + + return `${parseStep} + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + ${JSON.stringify(spec.execution.resultStepName)}: step, + }, + inputs: [], + output: this.outputPath, + }) + + return hasFailures ? 1 : undefined` + } + + if (spec.execution.kind === 'remote-preview') { + return ` const previewStep = parseIntentStep({ + schema: ${spec.schemaOptions?.importName}, + fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ')}, + fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, + rawValues: ${formatRawValues(fieldSpecs)}, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + ${JSON.stringify(spec.execution.importStepName)}: { + robot: '/http/import', + url: this.input, + }, + ${JSON.stringify(spec.execution.previewStepName)}: { + ...previewStep, + use: ${JSON.stringify(spec.execution.importStepName)}, + }, + }, + inputs: [], + output: this.outputPath, + }) + + return hasFailures ? 1 : undefined` + } + + if (spec.input.kind !== 'local-files') { + throw new Error(`Template command ${spec.className} requires local-files input`) + } + + return `${formatLocalValidation(spec.input, spec.paths[0].join(' '))} + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + template: ${JSON.stringify(spec.execution.templateId)}, +${formatLocalCreateOptions(spec.input)} + }) + + return hasFailures ? 1 : undefined` +} + +function generateImports(): string { + const imports = new Map() + + for (const spec of intentCommandSpecs) { + if (!spec.schemaOptions) continue + imports.set(spec.schemaOptions.importName, spec.schemaOptions.importPath) + } + + return [...imports.entries()] + .sort(([nameA], [nameB]) => nameA.localeCompare(nameB)) + .map(([importName, importPath]) => `import { ${importName} } from '${importPath}'`) + .join('\n') +} + +function generateClass(spec: IntentCommandSpec): string { + const fieldSpecs = spec.schemaOptions == null ? [] : collectSchemaFields(spec.schemaOptions) + const schemaFields = formatSchemaFields(fieldSpecs) + const inputOptions = formatInputOptions(spec) + const runBody = formatRunBody(spec, fieldSpecs) + + return ` +export class ${spec.className} extends AuthenticatedCommand { + static override paths = ${JSON.stringify(spec.paths)} + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: ${JSON.stringify(spec.description)}, + details: ${JSON.stringify(spec.details ?? '')}, + examples: [ +${formatUsageExamples(spec.examples)} + ], + }) + +${schemaFields}${schemaFields && inputOptions ? '\n\n' : ''}${inputOptions} + + outputPath = Option.String('--out,-o', { + description: ${JSON.stringify(spec.outputDescription)}, + required: ${spec.outputRequired}, + }) + + protected async run(): Promise { +${runBody} + } +} +` +} + +function generateFile(): string { + const commandClasses = intentCommandSpecs.map(generateClass) + const commandNames = intentCommandSpecs.map((spec) => spec.className) + + return `// DO NOT EDIT BY HAND. +// Generated by \`packages/node/scripts/generate-intent-commands.ts\`. + +import { Command, Option } from 'clipanion' +import * as t from 'typanion' + +${generateImports()} +import * as assembliesCommands from './assemblies.ts' +import { AuthenticatedCommand } from './BaseCommand.ts' +import { parseIntentStep } from '../intentRuntime.ts' +${commandClasses.join('\n')} +export const intentCommands = [ +${commandNames.map((name) => ` ${name},`).join('\n')} +] as const +` +} + +async function main(): Promise { + await mkdir(path.dirname(outputPath), { recursive: true }) + await writeFile(outputPath, generateFile()) + await execa( + 'yarn', + ['exec', 'biome', 'check', '--write', path.relative(packageRoot, outputPath)], + { + cwd: packageRoot, + }, + ) +} + +main().catch((error) => { + if (!(error instanceof Error)) { + throw new Error(`Was thrown a non-error: ${error}`) + } + + console.error(error) + process.exit(1) +}) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 5878b93a..18ad3ef8 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -68,12 +68,11 @@ export { TimeoutError, UploadError, } from 'got' -export type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts' -export * from './apiTypes.ts' -export { InconsistentResponseError, ApiError } export { extractFieldNamesFromTemplate } from './alphalib/stepParsing.ts' // Builtin templates replace the legacy golden template helpers. export { mergeTemplateContent } from './alphalib/templateMerge.ts' +export type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts' +export * from './apiTypes.ts' export type { Base64Strategy, InputFile, @@ -93,6 +92,7 @@ export type { RobotParamHelp, } from './robots.ts' export { getRobotHelp, isKnownRobot, listRobots } from './robots.ts' +export { ApiError, InconsistentResponseError } const log = debug('transloadit') const logWarn = debug('transloadit:warn') diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index a3def35b..e0ccac0c 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -824,6 +824,7 @@ function makeJobEmitter( export interface AssembliesCreateOptions { steps?: string + stepsData?: StepsInput template?: string fields?: Record watch?: boolean @@ -844,6 +845,7 @@ export async function create( client: Transloadit, { steps, + stepsData, template, fields, watch: watchOption, @@ -864,7 +866,7 @@ export async function create( // Read steps file async before entering the Promise constructor // We use StepsInput (the input type) rather than Steps (the transformed output type) // to avoid zod adding default values that the API may reject - let stepsData: StepsInput | undefined + let effectiveStepsData = stepsData if (steps) { const stepsContent = await fsp.readFile(steps, 'utf8') const parsed: unknown = JSON.parse(stepsContent) @@ -883,7 +885,7 @@ export async function create( ) } } - stepsData = parsed as StepsInput + effectiveStepsData = parsed as StepsInput } // Determine output stat async before entering the Promise constructor @@ -908,7 +910,9 @@ export async function create( return new Promise((resolve, reject) => { const params: CreateAssemblyParams = ( - stepsData ? { steps: stepsData as CreateAssemblyParams['steps'] } : { template_id: template } + effectiveStepsData + ? { steps: effectiveStepsData as CreateAssemblyParams['steps'] } + : { template_id: template } ) as CreateAssemblyParams if (fields) { params.fields = fields diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts new file mode 100644 index 00000000..0932e6f8 --- /dev/null +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -0,0 +1,1778 @@ +// DO NOT EDIT BY HAND. +// Generated by `packages/node/scripts/generate-intent-commands.ts`. + +import { Command, Option } from 'clipanion' +import * as t from 'typanion' + +import { robotAudioWaveformInstructionsSchema } from '../../alphalib/types/robots/audio-waveform.ts' +import { robotDocumentAutorotateInstructionsSchema } from '../../alphalib/types/robots/document-autorotate.ts' +import { robotDocumentConvertInstructionsSchema } from '../../alphalib/types/robots/document-convert.ts' +import { robotDocumentOptimizeInstructionsSchema } from '../../alphalib/types/robots/document-optimize.ts' +import { robotDocumentThumbsInstructionsSchema } from '../../alphalib/types/robots/document-thumbs.ts' +import { robotFileCompressInstructionsSchema } from '../../alphalib/types/robots/file-compress.ts' +import { robotFileDecompressInstructionsSchema } from '../../alphalib/types/robots/file-decompress.ts' +import { robotFilePreviewInstructionsSchema } from '../../alphalib/types/robots/file-preview.ts' +import { robotImageBgremoveInstructionsSchema } from '../../alphalib/types/robots/image-bgremove.ts' +import { robotImageGenerateInstructionsSchema } from '../../alphalib/types/robots/image-generate.ts' +import { robotImageOptimizeInstructionsSchema } from '../../alphalib/types/robots/image-optimize.ts' +import { robotImageResizeInstructionsSchema } from '../../alphalib/types/robots/image-resize.ts' +import { robotTextSpeakInstructionsSchema } from '../../alphalib/types/robots/text-speak.ts' +import { robotVideoThumbsInstructionsSchema } from '../../alphalib/types/robots/video-thumbs.ts' +import { parseIntentStep } from '../intentRuntime.ts' +import * as assembliesCommands from './assemblies.ts' +import { AuthenticatedCommand } from './BaseCommand.ts' + +export class ImageGenerateCommand extends AuthenticatedCommand { + static override paths = [['image', 'generate']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Generate an image from a prompt', + details: + 'Creates a one-off assembly around `/image/generate` and downloads the result to `--out`.', + examples: [ + [ + 'Generate a PNG image', + 'transloadit image generate --prompt "A red bicycle in a studio" --out bicycle.png', + ], + [ + 'Pick a model and aspect ratio', + 'transloadit image generate --prompt "An astronaut riding a horse" --model flux-schnell --aspect-ratio 2:3 --out horse.png', + ], + ], + }) + + prompt = Option.String('--prompt', { + description: 'The prompt describing the desired image content.', + required: true, + }) + + model = Option.String('--model', { + description: 'The AI model to use for image generation. Defaults to google/nano-banana.', + }) + + format = Option.String('--format', { + description: 'Format of the generated image.', + }) + + seed = Option.String('--seed', { + description: 'Seed for the random number generator.', + }) + + aspectRatio = Option.String('--aspect-ratio', { + description: 'Aspect ratio of the generated image.', + }) + + height = Option.String('--height', { + description: 'Height of the generated image.', + }) + + width = Option.String('--width', { + description: 'Width of the generated image.', + }) + + style = Option.String('--style', { + description: 'Style of the generated image.', + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the generated image to this path', + required: true, + }) + + protected async run(): Promise { + const step = parseIntentStep({ + schema: robotImageGenerateInstructionsSchema, + fixedValues: { + robot: '/image/generate', + result: true, + }, + fieldSpecs: [ + { name: 'prompt', kind: 'string' }, + { name: 'model', kind: 'string' }, + { name: 'format', kind: 'string' }, + { name: 'seed', kind: 'number' }, + { name: 'aspect_ratio', kind: 'string' }, + { name: 'height', kind: 'number' }, + { name: 'width', kind: 'number' }, + { name: 'style', kind: 'string' }, + ], + rawValues: { + prompt: this.prompt, + model: this.model, + format: this.format, + seed: this.seed, + aspect_ratio: this.aspectRatio, + height: this.height, + width: this.width, + style: this.style, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + generated_image: step, + }, + inputs: [], + output: this.outputPath, + }) + + return hasFailures ? 1 : undefined + } +} + +export class PreviewGenerateCommand extends AuthenticatedCommand { + static override paths = [['preview', 'generate']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Generate a preview image for a remote file URL', + details: + 'Imports a remote file with `/http/import`, then runs `/file/preview` and downloads the preview to `--out`.', + examples: [ + [ + 'Preview a remote PDF', + 'transloadit preview generate --input https://example.com/file.pdf --width 300 --height 200 --out preview.png', + ], + [ + 'Pick a format and resize strategy', + 'transloadit preview generate --input https://example.com/file.mp4 --width 640 --height 360 --format jpg --resize-strategy fillcrop --out preview.jpg', + ], + ], + }) + + format = Option.String('--format', { + description: + 'The output format for the generated thumbnail image. If a short video clip is generated using the `clip` strategy, its format is defined by `clip_format`.', + }) + + width = Option.String('--width', { + description: 'Width of the thumbnail, in pixels.', + }) + + height = Option.String('--height', { + description: 'Height of the thumbnail, in pixels.', + }) + + resizeStrategy = Option.String('--resize-strategy', { + description: + 'To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. This happens, for example, when the dimensions of a frame extracted from a video do not match the chosen `width` and `height` parameters.\n\nSee the list of available [resize strategies](/docs/topics/resize-strategies/) for more details.', + }) + + input = Option.String('--input,-i', { + description: 'Remote URL to preview', + required: true, + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the generated preview image to this path', + required: true, + }) + + protected async run(): Promise { + const previewStep = parseIntentStep({ + schema: robotFilePreviewInstructionsSchema, + fixedValues: { + robot: '/file/preview', + result: true, + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + ], + rawValues: { + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + imported: { + robot: '/http/import', + url: this.input, + }, + preview: { + ...previewStep, + use: 'imported', + }, + }, + inputs: [], + output: this.outputPath, + }) + + return hasFailures ? 1 : undefined + } +} + +export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { + static override paths = [['image', 'remove-background']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Remove the background from an image', + details: 'Runs `/image/bgremove` on each input image and downloads the result to `--out`.', + examples: [ + [ + 'Remove the background from one image', + 'transloadit image remove-background --input portrait.png --out portrait-cutout.png', + ], + [ + 'Choose the output format', + 'transloadit image remove-background --input portrait.png --format webp --out portrait-cutout.webp', + ], + ], + }) + + select = Option.String('--select', { + description: 'Region to select and keep in the image. The other region is removed.', + }) + + format = Option.String('--format', { + description: 'Format of the generated image.', + }) + + provider = Option.String('--provider', { + description: 'Provider to use for removing the background.', + }) + + model = Option.String('--model', { + description: + 'Provider-specific model to use for removing the background. Mostly intended for testing and evaluation.', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the background-removed image to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('image remove-background requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotImageBgremoveInstructionsSchema, + fixedValues: { + robot: '/image/bgremove', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'select', kind: 'string' }, + { name: 'format', kind: 'string' }, + { name: 'provider', kind: 'string' }, + { name: 'model', kind: 'string' }, + ], + rawValues: { + select: this.select, + format: this.format, + provider: this.provider, + model: this.model, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + removed_background: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class ImageOptimizeCommand extends AuthenticatedCommand { + static override paths = [['image', 'optimize']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Optimize image file size', + details: 'Runs `/image/optimize` on each input image and downloads the result to `--out`.', + examples: [ + [ + 'Optimize a single image', + 'transloadit image optimize --input hero.jpg --out hero-optimized.jpg', + ], + [ + 'Prioritize compression ratio', + 'transloadit image optimize --input hero.jpg --priority compression-ratio --out hero-optimized.jpg', + ], + ], + }) + + priority = Option.String('--priority', { + description: + 'Provides different algorithms for better or worse compression for your images, but that run slower or faster. The value `"conversion-speed"` will result in an average compression ratio of 18%. `"compression-ratio"` will result in an average compression ratio of 31%.', + }) + + progressive = Option.String('--progressive', { + description: + 'Interlaces the image if set to `true`, which makes the result image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the image which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a loss of about 10% of the file size reduction.', + }) + + preserveMetaData = Option.String('--preserve-meta-data', { + description: + "Specifies if the image's metadata should be preserved during the optimization, or not. If it is not preserved, the file size is even further reduced. But be aware that this could strip a photographer's copyright information, which for obvious reasons can be frowned upon.", + }) + + fixBreakingImages = Option.String('--fix-breaking-images', { + description: + 'If set to `true` this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies. This can sometimes result in a larger file size, though.', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the optimized image to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('image optimize requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotImageOptimizeInstructionsSchema, + fixedValues: { + robot: '/image/optimize', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'priority', kind: 'string' }, + { name: 'progressive', kind: 'boolean' }, + { name: 'preserve_meta_data', kind: 'boolean' }, + { name: 'fix_breaking_images', kind: 'boolean' }, + ], + rawValues: { + priority: this.priority, + progressive: this.progressive, + preserve_meta_data: this.preserveMetaData, + fix_breaking_images: this.fixBreakingImages, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + optimized: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class ImageResizeCommand extends AuthenticatedCommand { + static override paths = [['image', 'resize']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Resize an image', + details: 'Runs `/image/resize` on each input image and downloads the result to `--out`.', + examples: [ + [ + 'Resize an image to 800×600', + 'transloadit image resize --input photo.jpg --width 800 --height 600 --out photo-resized.jpg', + ], + [ + 'Pad with a transparent background', + 'transloadit image resize --input logo.png --width 512 --height 512 --resize-strategy pad --background none --out logo-square.png', + ], + ], + }) + + format = Option.String('--format', { + description: + 'The output format for the modified image.\n\nSome of the most important available formats are `"jpg"`, `"png"`, `"gif"`, and `"tiff"`. For a complete lists of all formats that we can write to please check [our supported image formats list](/docs/supported-formats/image-formats/).\n\nIf `null` (default), then the input image\'s format will be used as the output format.\n\nIf you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead.', + }) + + width = Option.String('--width', { + description: + 'Width of the result in pixels. If not specified, will default to the width of the original.', + }) + + height = Option.String('--height', { + description: + 'Height of the new image, in pixels. If not specified, will default to the height of the input image.', + }) + + resizeStrategy = Option.String('--resize-strategy', { + description: 'See the list of available [resize strategies](/docs/topics/resize-strategies/).', + }) + + strip = Option.String('--strip', { + description: + 'Strips all metadata from the image. This is useful to keep thumbnails as small as possible.', + }) + + background = Option.String('--background', { + description: + 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (used for the `pad` resize strategy).\n\n**Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`.', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the resized image to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('image resize requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotImageResizeInstructionsSchema, + fixedValues: { + robot: '/image/resize', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'strip', kind: 'boolean' }, + { name: 'background', kind: 'string' }, + ], + rawValues: { + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + strip: this.strip, + background: this.background, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + resized: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class DocumentConvertCommand extends AuthenticatedCommand { + static override paths = [['document', 'convert']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Convert a document into another format', + details: + 'Runs `/document/convert` on each input file and downloads the converted result to `--out`.', + examples: [ + [ + 'Convert a document to PDF', + 'transloadit document convert --input proposal.docx --format pdf --out proposal.pdf', + ], + [ + 'Convert markdown using GitHub-flavored markdown', + 'transloadit document convert --input notes.md --format html --markdown-format gfm --out notes.html', + ], + ], + }) + + format = Option.String('--format', { + description: 'The desired format for document conversion.', + required: true, + }) + + markdownFormat = Option.String('--markdown-format', { + description: + 'Markdown can be represented in several [variants](https://www.iana.org/assignments/markdown-variants/markdown-variants.xhtml), so when using this Robot to transform Markdown into HTML please specify which revision is being used.', + }) + + markdownTheme = Option.String('--markdown-theme', { + description: + 'This parameter overhauls your Markdown files styling based on several canned presets.', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the converted document to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('document convert requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotDocumentConvertInstructionsSchema, + fixedValues: { + robot: '/document/convert', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'markdown_format', kind: 'string' }, + { name: 'markdown_theme', kind: 'string' }, + ], + rawValues: { + format: this.format, + markdown_format: this.markdownFormat, + markdown_theme: this.markdownTheme, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + converted: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class DocumentOptimizeCommand extends AuthenticatedCommand { + static override paths = [['document', 'optimize']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Reduce PDF file size', + details: + 'Runs `/document/optimize` on each input PDF and downloads the optimized result to `--out`.', + examples: [ + [ + 'Optimize a PDF with the ebook preset', + 'transloadit document optimize --input report.pdf --preset ebook --out report-optimized.pdf', + ], + [ + 'Override image DPI', + 'transloadit document optimize --input report.pdf --image-dpi 150 --out report-optimized.pdf', + ], + ], + }) + + preset = Option.String('--preset', { + description: + 'The quality preset to use for optimization. Each preset provides a different balance between file size and quality:\n\n- `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI.\n- `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI.\n- `printer` - High quality suitable for printing. Images are kept at 300 DPI.\n- `prepress` - Highest quality for professional printing. Minimal compression applied.', + }) + + imageDpi = Option.String('--image-dpi', { + description: + 'Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset.\n\nHigher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed.\n\nCommon values:\n- 72 - Screen viewing\n- 150 - eBooks and general documents\n- 300 - Print quality\n- 600 - High-quality print', + }) + + compressFonts = Option.String('--compress-fonts', { + description: + 'Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size.', + }) + + subsetFonts = Option.String('--subset-fonts', { + description: + "Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. This can significantly reduce file size for documents that only use a small portion of a font's character set.", + }) + + removeMetadata = Option.String('--remove-metadata', { + description: + 'Whether to strip document metadata (title, author, keywords, etc.) from the PDF. This can provide a small reduction in file size and may be useful for privacy.', + }) + + linearize = Option.String('--linearize', { + description: + 'Whether to linearize (optimize for Fast Web View) the output PDF. Linearized PDFs can begin displaying in a browser before they are fully downloaded, improving the user experience for web delivery.', + }) + + compatibility = Option.String('--compatibility', { + description: + 'The PDF version compatibility level. Lower versions have broader compatibility but fewer features. Higher versions support more advanced features but may not open in older PDF readers.\n\n- `1.4` - Acrobat 5 compatibility, most widely supported\n- `1.5` - Acrobat 6 compatibility\n- `1.6` - Acrobat 7 compatibility\n- `1.7` - Acrobat 8+ compatibility (default)\n- `2.0` - PDF 2.0 standard', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the optimized PDF to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('document optimize requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotDocumentOptimizeInstructionsSchema, + fixedValues: { + robot: '/document/optimize', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'preset', kind: 'string' }, + { name: 'image_dpi', kind: 'number' }, + { name: 'compress_fonts', kind: 'boolean' }, + { name: 'subset_fonts', kind: 'boolean' }, + { name: 'remove_metadata', kind: 'boolean' }, + { name: 'linearize', kind: 'boolean' }, + { name: 'compatibility', kind: 'string' }, + ], + rawValues: { + preset: this.preset, + image_dpi: this.imageDpi, + compress_fonts: this.compressFonts, + subset_fonts: this.subsetFonts, + remove_metadata: this.removeMetadata, + linearize: this.linearize, + compatibility: this.compatibility, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + optimized: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class DocumentAutoRotateCommand extends AuthenticatedCommand { + static override paths = [['document', 'auto-rotate']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Correct document page orientation', + details: + 'Runs `/document/autorotate` on each input file and downloads the corrected document to `--out`.', + examples: [ + [ + 'Auto-rotate a scanned PDF', + 'transloadit document auto-rotate --input scans.pdf --out scans-corrected.pdf', + ], + ], + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the auto-rotated document to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('document auto-rotate requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotDocumentAutorotateInstructionsSchema, + fixedValues: { + robot: '/document/autorotate', + result: true, + use: ':original', + }, + fieldSpecs: [], + rawValues: {}, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + autorotated: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class DocumentThumbsCommand extends AuthenticatedCommand { + static override paths = [['document', 'thumbs']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Render thumbnails from a document', + details: + 'Runs `/document/thumbs` on each input document and writes the extracted pages or animated GIF to `--out`.', + examples: [ + [ + 'Extract PNG thumbnails from every page', + 'transloadit document thumbs --input brochure.pdf --width 240 --out thumbs/', + ], + [ + 'Generate an animated GIF preview', + 'transloadit document thumbs --input brochure.pdf --format gif --delay 50 --out brochure.gif', + ], + ], + }) + + page = Option.String('--page', { + description: + 'The PDF page that you want to convert to an image. By default the value is `null` which means that all pages will be converted into images.', + }) + + format = Option.String('--format', { + description: + 'The format of the extracted image(s).\n\nIf you specify the value `"gif"`, then an animated gif cycling through all pages is created. Please check out [this demo](/demos/document-processing/convert-all-pages-of-a-document-into-an-animated-gif/) to learn more about this.', + }) + + delay = Option.String('--delay', { + description: + 'If your output format is `"gif"` then this parameter sets the number of 100th seconds to pass before the next frame is shown in the animation. Set this to `100` for example to allow 1 second to pass between the frames of the animated gif.\n\nIf your output format is not `"gif"`, then this parameter does not have any effect.', + }) + + width = Option.String('--width', { + description: + 'Width of the new image, in pixels. If not specified, will default to the width of the input image', + }) + + height = Option.String('--height', { + description: + 'Height of the new image, in pixels. If not specified, will default to the height of the input image', + }) + + resizeStrategy = Option.String('--resize-strategy', { + description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', + }) + + background = Option.String('--background', { + description: + 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (only used for the pad resize strategy).\n\nBy default, the background of transparent images is changed to white. For details about how to preserve transparency across all image types, see [this demo](/demos/image-manipulation/properly-preserve-transparency-across-all-image-types/).', + }) + + trimWhitespace = Option.String('--trim-whitespace', { + description: + "This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. If you set this to `true` only the real PDF page contents will be shown in the image.\n\nIf you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`.", + }) + + pdfUseCropbox = Option.String('--pdf-use-cropbox', { + description: + "Some PDF documents lie about their dimensions. For instance they'll say they are landscape, but when opened in decent Desktop readers, it's really in portrait mode. This can happen if the document has a cropbox defined. When this option is enabled (by default), the cropbox is leading in determining the dimensions of the resulting thumbnails.", + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the extracted document thumbnails to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('document thumbs requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotDocumentThumbsInstructionsSchema, + fixedValues: { + robot: '/document/thumbs', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'page', kind: 'number' }, + { name: 'format', kind: 'string' }, + { name: 'delay', kind: 'number' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'trim_whitespace', kind: 'boolean' }, + { name: 'pdf_use_cropbox', kind: 'boolean' }, + ], + rawValues: { + page: this.page, + format: this.format, + delay: this.delay, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + trim_whitespace: this.trimWhitespace, + pdf_use_cropbox: this.pdfUseCropbox, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + thumbnailed: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class AudioWaveformCommand extends AuthenticatedCommand { + static override paths = [['audio', 'waveform']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Generate a waveform image from audio', + details: + 'Runs `/audio/waveform` on each input audio file and downloads the waveform to `--out`.', + examples: [ + [ + 'Generate a waveform PNG', + 'transloadit audio waveform --input podcast.mp3 --width 1200 --height 300 --out waveform.png', + ], + [ + 'Generate waveform JSON', + 'transloadit audio waveform --input podcast.mp3 --format json --out waveform.json', + ], + ], + }) + + format = Option.String('--format', { + description: + 'The format of the result file. Can be `"image"` or `"json"`. If `"image"` is supplied, a PNG image will be created, otherwise a JSON file.', + }) + + width = Option.String('--width', { + description: 'The width of the resulting image if the format `"image"` was selected.', + }) + + height = Option.String('--height', { + description: 'The height of the resulting image if the format `"image"` was selected.', + }) + + style = Option.String('--style', { + description: + 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', + required: true, + }) + + backgroundColor = Option.String('--background-color', { + description: + 'The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected.', + }) + + centerColor = Option.String('--center-color', { + description: + 'The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', + }) + + outerColor = Option.String('--outer-color', { + description: + 'The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the waveform image or JSON data to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('audio waveform requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotAudioWaveformInstructionsSchema, + fixedValues: { + robot: '/audio/waveform', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'style', kind: 'string' }, + { name: 'background_color', kind: 'string' }, + { name: 'center_color', kind: 'string' }, + { name: 'outer_color', kind: 'string' }, + ], + rawValues: { + format: this.format, + width: this.width, + height: this.height, + style: this.style, + background_color: this.backgroundColor, + center_color: this.centerColor, + outer_color: this.outerColor, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + waveformed: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class TextSpeakCommand extends AuthenticatedCommand { + static override paths = [['text', 'speak']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Turn a text prompt into spoken audio', + details: 'Runs `/text/speak` with a prompt and downloads the synthesized audio to `--out`.', + examples: [ + [ + 'Speak a sentence in American English', + 'transloadit text speak --prompt "Hello world" --provider aws --target-language en-US --out hello.mp3', + ], + [ + 'Use a different voice', + 'transloadit text speak --prompt "Bonjour tout le monde" --provider aws --target-language fr-FR --voice female-2 --out bonjour.mp3', + ], + ], + }) + + prompt = Option.String('--prompt', { + description: + 'Which text to speak. You can also set this to `null` and supply an input text file.', + required: true, + }) + + provider = Option.String('--provider', { + description: + 'Which AI provider to leverage.\n\nTransloadit outsources this task and abstracts the interface so you can expect the same data structures, but different latencies and information being returned. Different cloud vendors have different areas they shine in, and we recommend to try out and see what yields the best results for your use case.', + required: true, + }) + + targetLanguage = Option.String('--target-language', { + description: + 'The written language of the document. This will also be the language of the spoken text.\n\nThe language should be specified in the [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) format, such as `"en-GB"`, `"de-DE"` or `"fr-FR"`. Please consult the list of supported languages and voices.', + }) + + voice = Option.String('--voice', { + description: + 'The gender to be used for voice synthesis. Please consult the list of supported languages and voices.', + }) + + ssml = Option.String('--ssml', { + description: + 'Supply [Speech Synthesis Markup Language](https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language) instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations.\n\nPlease see the supported syntaxes for [AWS](https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html) and [GCP](https://cloud.google.com/text-to-speech/docs/ssml).', + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the synthesized audio to this path', + required: true, + }) + + protected async run(): Promise { + const step = parseIntentStep({ + schema: robotTextSpeakInstructionsSchema, + fixedValues: { + robot: '/text/speak', + result: true, + }, + fieldSpecs: [ + { name: 'prompt', kind: 'string' }, + { name: 'provider', kind: 'string' }, + { name: 'target_language', kind: 'string' }, + { name: 'voice', kind: 'string' }, + { name: 'ssml', kind: 'boolean' }, + ], + rawValues: { + prompt: this.prompt, + provider: this.provider, + target_language: this.targetLanguage, + voice: this.voice, + ssml: this.ssml, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + synthesized: step, + }, + inputs: [], + output: this.outputPath, + }) + + return hasFailures ? 1 : undefined + } +} + +export class VideoThumbsCommand extends AuthenticatedCommand { + static override paths = [['video', 'thumbs']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Extract thumbnails from a video', + details: 'Runs `/video/thumbs` on each input video and writes the extracted images to `--out`.', + examples: [ + [ + 'Extract eight thumbnails', + 'transloadit video thumbs --input demo.mp4 --count 8 --out thumbs/', + ], + [ + 'Resize thumbnails to PNG', + 'transloadit video thumbs --input demo.mp4 --count 5 --format png --width 640 --out thumbs/', + ], + ], + }) + + count = Option.String('--count', { + description: + 'The number of thumbnails to be extracted. As some videos have incorrect durations, the actual number of thumbnails generated may be less in rare cases. The maximum number of thumbnails we currently allow is 999.\n\nThe thumbnails are taken at regular intervals, determined by dividing the video duration by the count. For example, a count of 3 will produce thumbnails at 25%, 50% and 75% through the video.\n\nTo extract thumbnails for specific timestamps, use the `offsets` parameter.', + }) + + format = Option.String('--format', { + description: + 'The format of the extracted thumbnail. Supported values are `"jpg"`, `"jpeg"` and `"png"`. Even if you specify the format to be `"jpeg"` the resulting thumbnails will have a `"jpg"` file extension.', + }) + + width = Option.String('--width', { + description: + 'The width of the thumbnail, in pixels. Defaults to the original width of the video.', + }) + + height = Option.String('--height', { + description: + 'The height of the thumbnail, in pixels. Defaults to the original height of the video.', + }) + + resizeStrategy = Option.String('--resize-strategy', { + description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', + }) + + background = Option.String('--background', { + description: + 'The background color of the resulting thumbnails in the `"rrggbbaa"` format (red, green, blue, alpha) when used with the `"pad"` resize strategy. The default color is black.', + }) + + rotate = Option.String('--rotate', { + description: + 'Forces the video to be rotated by the specified degree integer. Currently, only multiples of 90 are supported. We automatically correct the orientation of many videos when the orientation is provided by the camera. This option is only useful for videos requiring rotation because it was not detected by the camera.', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the extracted video thumbnails to this path or directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('video thumbs requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotVideoThumbsInstructionsSchema, + fixedValues: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'count', kind: 'number' }, + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'rotate', kind: 'string' }, + ], + rawValues: { + count: this.count, + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + rotate: this.rotate, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + thumbnailed: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class VideoEncodeHlsCommand extends AuthenticatedCommand { + static override paths = [['video', 'encode-hls']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Encode a video into an HLS package', + details: + 'Runs the `builtin/encode-hls-video@latest` builtin template and downloads the HLS outputs into `--out`.', + examples: [ + ['Encode a single video', 'transloadit video encode-hls --input input.mp4 --out dist/hls'], + [ + 'Process a directory recursively', + 'transloadit video encode-hls --input videos/ --out dist/hls --recursive', + ], + ], + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the HLS outputs into this directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('video encode-hls requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + template: 'builtin/encode-hls-video@latest', + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export class FileCompressCommand extends AuthenticatedCommand { + static override paths = [['file', 'compress']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Create an archive from one or more files', + details: + 'Runs `/file/compress` and writes the resulting archive to `--out`. Multiple inputs are bundled into one archive by default.', + examples: [ + [ + 'Create a ZIP archive', + 'transloadit file compress --input assets/ --format zip --out assets.zip', + ], + [ + 'Create a gzipped tarball', + 'transloadit file compress --input assets/ --format tar --gzip true --out assets.tar.gz', + ], + ], + }) + + format = Option.String('--format', { + description: + 'The format of the archive to be created. Supported values are `"tar"` and `"zip"`.\n\nNote that `"tar"` without setting `gzip` to `true` results in an archive that\'s not compressed in any way.', + }) + + gzip = Option.String('--gzip', { + description: + 'Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format.', + }) + + password = Option.String('--password', { + description: + 'This allows you to encrypt all archive contents with a password and thereby protect it against unauthorized use. To unzip the archive, the user will need to provide the password in a text input field prompt.\n\nThis parameter has no effect if the format parameter is anything other than `"zip"`.', + }) + + compressionLevel = Option.String('--compression-level', { + description: + 'Determines how fiercely to try to compress the archive. `-0` is compressionless, which is suitable for media that is already compressed. `-1` is fastest with lowest compression. `-9` is slowest with the highest compression.\n\nIf you are using `-0` in combination with the `tar` format with `gzip` enabled, consider setting `gzip: false` instead. This results in a plain Tar archive, meaning it already has no compression.', + }) + + fileLayout = Option.String('--file-layout', { + description: + 'Determines if the result archive should contain all files in one directory (value for this is `"simple"`) or in subfolders according to the explanation below (value for this is `"advanced"`). The `"relative-path"` option preserves the relative directory structure of the input files.\n\nFiles with same names are numbered in the `"simple"` file layout to avoid naming collisions.', + }) + + archiveName = Option.String('--archive-name', { + description: 'The name of the archive file to be created (without the file extension).', + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide one or more input files or directories', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the generated archive to this path', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('file compress requires at least one --input') + return 1 + } + + const step = parseIntentStep({ + schema: robotFileCompressInstructionsSchema, + fixedValues: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'gzip', kind: 'boolean' }, + { name: 'password', kind: 'string' }, + { name: 'compression_level', kind: 'number' }, + { name: 'file_layout', kind: 'string' }, + { name: 'archive_name', kind: 'string' }, + ], + rawValues: { + format: this.format, + gzip: this.gzip, + password: this.password, + compression_level: this.compressionLevel, + file_layout: this.fileLayout, + archive_name: this.archiveName, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + compressed: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: true, + }) + + return hasFailures ? 1 : undefined + } +} + +export class FileDecompressCommand extends AuthenticatedCommand { + static override paths = [['file', 'decompress']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Decompress an archive', + details: + 'Runs `/file/decompress` on each input archive and writes the extracted files to `--out`.', + examples: [ + [ + 'Decompress a ZIP archive', + 'transloadit file decompress --input assets.zip --out extracted/', + ], + ], + }) + + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the extracted files to this directory', + required: true, + }) + + protected async run(): Promise { + if ((this.inputs ?? []).length === 0) { + this.output.error('file decompress requires at least one --input') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const step = parseIntentStep({ + schema: robotFileDecompressInstructionsSchema, + fixedValues: { + robot: '/file/decompress', + result: true, + use: ':original', + }, + fieldSpecs: [], + rawValues: {}, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + decompressed: step, + }, + inputs: this.inputs ?? [], + output: this.outputPath, + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } +} + +export const intentCommands = [ + ImageGenerateCommand, + PreviewGenerateCommand, + ImageRemoveBackgroundCommand, + ImageOptimizeCommand, + ImageResizeCommand, + DocumentConvertCommand, + DocumentOptimizeCommand, + DocumentAutoRotateCommand, + DocumentThumbsCommand, + AudioWaveformCommand, + TextSpeakCommand, + VideoThumbsCommand, + VideoEncodeHlsCommand, + FileCompressCommand, + FileDecompressCommand, +] as const diff --git a/packages/node/src/cli/commands/index.ts b/packages/node/src/cli/commands/index.ts index 8f048784..5abcbaf3 100644 --- a/packages/node/src/cli/commands/index.ts +++ b/packages/node/src/cli/commands/index.ts @@ -15,6 +15,7 @@ import { SignatureCommand, SmartCdnSignatureCommand, TokenCommand } from './auth import { BillsGetCommand } from './bills.ts' import { DocsRobotsGetCommand, DocsRobotsListCommand } from './docs.ts' +import { intentCommands } from './generated-intents.ts' import { NotificationsReplayCommand } from './notifications.ts' import { TemplatesCreateCommand, @@ -71,5 +72,10 @@ export function createCli(): Cli { cli.register(DocsRobotsListCommand) cli.register(DocsRobotsGetCommand) + // Intent-first commands + for (const command of intentCommands) { + cli.register(command) + } + return cli } diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts new file mode 100644 index 00000000..ed7a9c23 --- /dev/null +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -0,0 +1,652 @@ +import type { z } from 'zod' + +import { robotAudioWaveformInstructionsSchema } from '../alphalib/types/robots/audio-waveform.ts' +import { robotDocumentAutorotateInstructionsSchema } from '../alphalib/types/robots/document-autorotate.ts' +import { robotDocumentConvertInstructionsSchema } from '../alphalib/types/robots/document-convert.ts' +import { robotDocumentOptimizeInstructionsSchema } from '../alphalib/types/robots/document-optimize.ts' +import { robotDocumentThumbsInstructionsSchema } from '../alphalib/types/robots/document-thumbs.ts' +import { robotFileCompressInstructionsSchema } from '../alphalib/types/robots/file-compress.ts' +import { robotFileDecompressInstructionsSchema } from '../alphalib/types/robots/file-decompress.ts' +import { robotFilePreviewInstructionsSchema } from '../alphalib/types/robots/file-preview.ts' +import { robotImageBgremoveInstructionsSchema } from '../alphalib/types/robots/image-bgremove.ts' +import { robotImageGenerateInstructionsSchema } from '../alphalib/types/robots/image-generate.ts' +import { robotImageOptimizeInstructionsSchema } from '../alphalib/types/robots/image-optimize.ts' +import { robotImageResizeInstructionsSchema } from '../alphalib/types/robots/image-resize.ts' +import { robotTextSpeakInstructionsSchema } from '../alphalib/types/robots/text-speak.ts' +import { robotVideoThumbsInstructionsSchema } from '../alphalib/types/robots/video-thumbs.ts' + +export interface IntentSchemaOptionSpec { + importName: string + importPath: string + keys: string[] + requiredKeys?: string[] + schema: z.AnyZodObject +} + +export interface IntentInputNoneSpec { + kind: 'none' +} + +export interface IntentInputRemoteUrlSpec { + description: string + kind: 'remote-url' +} + +export interface IntentInputLocalFilesSpec { + allowConcurrency?: boolean + allowSingleAssembly?: boolean + allowWatch?: boolean + defaultSingleAssembly?: boolean + deleteAfterProcessing?: boolean + description: string + kind: 'local-files' + recursive?: boolean + reprocessStale?: boolean +} + +export type IntentInputSpec = + | IntentInputLocalFilesSpec + | IntentInputNoneSpec + | IntentInputRemoteUrlSpec + +export interface IntentTemplateExecutionSpec { + kind: 'template' + templateId: string +} + +export interface IntentSingleStepExecutionSpec { + fixedValues: Record + kind: 'single-step' + resultStepName: string +} + +export interface IntentRemotePreviewExecutionSpec { + fixedValues: Record + importStepName: string + kind: 'remote-preview' + previewStepName: string +} + +export type IntentExecutionSpec = + | IntentRemotePreviewExecutionSpec + | IntentSingleStepExecutionSpec + | IntentTemplateExecutionSpec + +export interface IntentCommandSpec { + className: string + description: string + details?: string + examples: Array<[string, string]> + execution: IntentExecutionSpec + input: IntentInputSpec + outputDescription: string + outputRequired: boolean + paths: string[][] + schemaOptions?: IntentSchemaOptionSpec + summary: string +} + +const localFileInput = { + kind: 'local-files', + description: 'Provide an input file or a directory', + recursive: true, + allowWatch: true, + deleteAfterProcessing: true, + reprocessStale: true, + allowSingleAssembly: true, + allowConcurrency: true, +} satisfies IntentInputLocalFilesSpec + +export const intentCommandSpecs = [ + { + className: 'ImageGenerateCommand', + summary: 'Generate images from text prompts', + description: 'Generate an image from a prompt', + details: + 'Creates a one-off assembly around `/image/generate` and downloads the result to `--out`.', + paths: [['image', 'generate']], + input: { kind: 'none' }, + outputDescription: 'Write the generated image to this path', + outputRequired: true, + examples: [ + [ + 'Generate a PNG image', + 'transloadit image generate --prompt "A red bicycle in a studio" --out bicycle.png', + ], + [ + 'Pick a model and aspect ratio', + 'transloadit image generate --prompt "An astronaut riding a horse" --model flux-schnell --aspect-ratio 2:3 --out horse.png', + ], + ], + schemaOptions: { + importName: 'robotImageGenerateInstructionsSchema', + importPath: '../../alphalib/types/robots/image-generate.ts', + schema: robotImageGenerateInstructionsSchema, + keys: ['prompt', 'model', 'format', 'seed', 'aspect_ratio', 'height', 'width', 'style'], + }, + execution: { + kind: 'single-step', + resultStepName: 'generated_image', + fixedValues: { + robot: '/image/generate', + result: true, + }, + }, + }, + { + className: 'PreviewGenerateCommand', + summary: 'Generate preview thumbnails for remote files', + description: 'Generate a preview image for a remote file URL', + details: + 'Imports a remote file with `/http/import`, then runs `/file/preview` and downloads the preview to `--out`.', + paths: [['preview', 'generate']], + input: { + kind: 'remote-url', + description: 'Remote URL to preview', + }, + outputDescription: 'Write the generated preview image to this path', + outputRequired: true, + examples: [ + [ + 'Preview a remote PDF', + 'transloadit preview generate --input https://example.com/file.pdf --width 300 --height 200 --out preview.png', + ], + [ + 'Pick a format and resize strategy', + 'transloadit preview generate --input https://example.com/file.mp4 --width 640 --height 360 --format jpg --resize-strategy fillcrop --out preview.jpg', + ], + ], + schemaOptions: { + importName: 'robotFilePreviewInstructionsSchema', + importPath: '../../alphalib/types/robots/file-preview.ts', + schema: robotFilePreviewInstructionsSchema, + keys: ['format', 'width', 'height', 'resize_strategy'], + }, + execution: { + kind: 'remote-preview', + importStepName: 'imported', + previewStepName: 'preview', + fixedValues: { + robot: '/file/preview', + result: true, + }, + }, + }, + { + className: 'ImageRemoveBackgroundCommand', + summary: 'Remove image backgrounds', + description: 'Remove the background from an image', + details: 'Runs `/image/bgremove` on each input image and downloads the result to `--out`.', + paths: [['image', 'remove-background']], + input: localFileInput, + outputDescription: 'Write the background-removed image to this path or directory', + outputRequired: true, + examples: [ + [ + 'Remove the background from one image', + 'transloadit image remove-background --input portrait.png --out portrait-cutout.png', + ], + [ + 'Choose the output format', + 'transloadit image remove-background --input portrait.png --format webp --out portrait-cutout.webp', + ], + ], + schemaOptions: { + importName: 'robotImageBgremoveInstructionsSchema', + importPath: '../../alphalib/types/robots/image-bgremove.ts', + schema: robotImageBgremoveInstructionsSchema, + keys: ['select', 'format', 'provider', 'model'], + }, + execution: { + kind: 'single-step', + resultStepName: 'removed_background', + fixedValues: { + robot: '/image/bgremove', + result: true, + use: ':original', + }, + }, + }, + { + className: 'ImageOptimizeCommand', + summary: 'Optimize images', + description: 'Optimize image file size', + details: 'Runs `/image/optimize` on each input image and downloads the result to `--out`.', + paths: [['image', 'optimize']], + input: localFileInput, + outputDescription: 'Write the optimized image to this path or directory', + outputRequired: true, + examples: [ + [ + 'Optimize a single image', + 'transloadit image optimize --input hero.jpg --out hero-optimized.jpg', + ], + [ + 'Prioritize compression ratio', + 'transloadit image optimize --input hero.jpg --priority compression-ratio --out hero-optimized.jpg', + ], + ], + schemaOptions: { + importName: 'robotImageOptimizeInstructionsSchema', + importPath: '../../alphalib/types/robots/image-optimize.ts', + schema: robotImageOptimizeInstructionsSchema, + keys: ['priority', 'progressive', 'preserve_meta_data', 'fix_breaking_images'], + }, + execution: { + kind: 'single-step', + resultStepName: 'optimized', + fixedValues: { + robot: '/image/optimize', + result: true, + use: ':original', + }, + }, + }, + { + className: 'ImageResizeCommand', + summary: 'Resize images', + description: 'Resize an image', + details: 'Runs `/image/resize` on each input image and downloads the result to `--out`.', + paths: [['image', 'resize']], + input: localFileInput, + outputDescription: 'Write the resized image to this path or directory', + outputRequired: true, + examples: [ + [ + 'Resize an image to 800×600', + 'transloadit image resize --input photo.jpg --width 800 --height 600 --out photo-resized.jpg', + ], + [ + 'Pad with a transparent background', + 'transloadit image resize --input logo.png --width 512 --height 512 --resize-strategy pad --background none --out logo-square.png', + ], + ], + schemaOptions: { + importName: 'robotImageResizeInstructionsSchema', + importPath: '../../alphalib/types/robots/image-resize.ts', + schema: robotImageResizeInstructionsSchema, + keys: ['format', 'width', 'height', 'resize_strategy', 'strip', 'background'], + }, + execution: { + kind: 'single-step', + resultStepName: 'resized', + fixedValues: { + robot: '/image/resize', + result: true, + use: ':original', + }, + }, + }, + { + className: 'DocumentConvertCommand', + summary: 'Convert documents', + description: 'Convert a document into another format', + details: + 'Runs `/document/convert` on each input file and downloads the converted result to `--out`.', + paths: [['document', 'convert']], + input: localFileInput, + outputDescription: 'Write the converted document to this path or directory', + outputRequired: true, + examples: [ + [ + 'Convert a document to PDF', + 'transloadit document convert --input proposal.docx --format pdf --out proposal.pdf', + ], + [ + 'Convert markdown using GitHub-flavored markdown', + 'transloadit document convert --input notes.md --format html --markdown-format gfm --out notes.html', + ], + ], + schemaOptions: { + importName: 'robotDocumentConvertInstructionsSchema', + importPath: '../../alphalib/types/robots/document-convert.ts', + schema: robotDocumentConvertInstructionsSchema, + keys: ['format', 'markdown_format', 'markdown_theme'], + }, + execution: { + kind: 'single-step', + resultStepName: 'converted', + fixedValues: { + robot: '/document/convert', + result: true, + use: ':original', + }, + }, + }, + { + className: 'DocumentOptimizeCommand', + summary: 'Optimize PDF documents', + description: 'Reduce PDF file size', + details: + 'Runs `/document/optimize` on each input PDF and downloads the optimized result to `--out`.', + paths: [['document', 'optimize']], + input: localFileInput, + outputDescription: 'Write the optimized PDF to this path or directory', + outputRequired: true, + examples: [ + [ + 'Optimize a PDF with the ebook preset', + 'transloadit document optimize --input report.pdf --preset ebook --out report-optimized.pdf', + ], + [ + 'Override image DPI', + 'transloadit document optimize --input report.pdf --image-dpi 150 --out report-optimized.pdf', + ], + ], + schemaOptions: { + importName: 'robotDocumentOptimizeInstructionsSchema', + importPath: '../../alphalib/types/robots/document-optimize.ts', + schema: robotDocumentOptimizeInstructionsSchema, + keys: [ + 'preset', + 'image_dpi', + 'compress_fonts', + 'subset_fonts', + 'remove_metadata', + 'linearize', + 'compatibility', + ], + }, + execution: { + kind: 'single-step', + resultStepName: 'optimized', + fixedValues: { + robot: '/document/optimize', + result: true, + use: ':original', + }, + }, + }, + { + className: 'DocumentAutoRotateCommand', + summary: 'Auto-rotate documents', + description: 'Correct document page orientation', + details: + 'Runs `/document/autorotate` on each input file and downloads the corrected document to `--out`.', + paths: [['document', 'auto-rotate']], + input: localFileInput, + outputDescription: 'Write the auto-rotated document to this path or directory', + outputRequired: true, + examples: [ + [ + 'Auto-rotate a scanned PDF', + 'transloadit document auto-rotate --input scans.pdf --out scans-corrected.pdf', + ], + ], + schemaOptions: { + importName: 'robotDocumentAutorotateInstructionsSchema', + importPath: '../../alphalib/types/robots/document-autorotate.ts', + schema: robotDocumentAutorotateInstructionsSchema, + keys: [], + }, + execution: { + kind: 'single-step', + resultStepName: 'autorotated', + fixedValues: { + robot: '/document/autorotate', + result: true, + use: ':original', + }, + }, + }, + { + className: 'DocumentThumbsCommand', + summary: 'Extract document thumbnails', + description: 'Render thumbnails from a document', + details: + 'Runs `/document/thumbs` on each input document and writes the extracted pages or animated GIF to `--out`.', + paths: [['document', 'thumbs']], + input: localFileInput, + outputDescription: 'Write the extracted document thumbnails to this path or directory', + outputRequired: true, + examples: [ + [ + 'Extract PNG thumbnails from every page', + 'transloadit document thumbs --input brochure.pdf --width 240 --out thumbs/', + ], + [ + 'Generate an animated GIF preview', + 'transloadit document thumbs --input brochure.pdf --format gif --delay 50 --out brochure.gif', + ], + ], + schemaOptions: { + importName: 'robotDocumentThumbsInstructionsSchema', + importPath: '../../alphalib/types/robots/document-thumbs.ts', + schema: robotDocumentThumbsInstructionsSchema, + keys: [ + 'page', + 'format', + 'delay', + 'width', + 'height', + 'resize_strategy', + 'background', + 'trim_whitespace', + 'pdf_use_cropbox', + ], + }, + execution: { + kind: 'single-step', + resultStepName: 'thumbnailed', + fixedValues: { + robot: '/document/thumbs', + result: true, + use: ':original', + }, + }, + }, + { + className: 'AudioWaveformCommand', + summary: 'Generate audio waveforms', + description: 'Generate a waveform image from audio', + details: + 'Runs `/audio/waveform` on each input audio file and downloads the waveform to `--out`.', + paths: [['audio', 'waveform']], + input: localFileInput, + outputDescription: 'Write the waveform image or JSON data to this path or directory', + outputRequired: true, + examples: [ + [ + 'Generate a waveform PNG', + 'transloadit audio waveform --input podcast.mp3 --width 1200 --height 300 --out waveform.png', + ], + [ + 'Generate waveform JSON', + 'transloadit audio waveform --input podcast.mp3 --format json --out waveform.json', + ], + ], + schemaOptions: { + importName: 'robotAudioWaveformInstructionsSchema', + importPath: '../../alphalib/types/robots/audio-waveform.ts', + schema: robotAudioWaveformInstructionsSchema, + keys: [ + 'format', + 'width', + 'height', + 'style', + 'background_color', + 'center_color', + 'outer_color', + ], + }, + execution: { + kind: 'single-step', + resultStepName: 'waveformed', + fixedValues: { + robot: '/audio/waveform', + result: true, + use: ':original', + }, + }, + }, + { + className: 'TextSpeakCommand', + summary: 'Synthesize speech from text', + description: 'Turn a text prompt into spoken audio', + details: 'Runs `/text/speak` with a prompt and downloads the synthesized audio to `--out`.', + paths: [['text', 'speak']], + input: { kind: 'none' }, + outputDescription: 'Write the synthesized audio to this path', + outputRequired: true, + examples: [ + [ + 'Speak a sentence in American English', + 'transloadit text speak --prompt "Hello world" --provider aws --target-language en-US --out hello.mp3', + ], + [ + 'Use a different voice', + 'transloadit text speak --prompt "Bonjour tout le monde" --provider aws --target-language fr-FR --voice female-2 --out bonjour.mp3', + ], + ], + schemaOptions: { + importName: 'robotTextSpeakInstructionsSchema', + importPath: '../../alphalib/types/robots/text-speak.ts', + schema: robotTextSpeakInstructionsSchema, + keys: ['prompt', 'provider', 'target_language', 'voice', 'ssml'], + requiredKeys: ['prompt'], + }, + execution: { + kind: 'single-step', + resultStepName: 'synthesized', + fixedValues: { + robot: '/text/speak', + result: true, + }, + }, + }, + { + className: 'VideoThumbsCommand', + summary: 'Extract video thumbnails', + description: 'Extract thumbnails from a video', + details: 'Runs `/video/thumbs` on each input video and writes the extracted images to `--out`.', + paths: [['video', 'thumbs']], + input: localFileInput, + outputDescription: 'Write the extracted video thumbnails to this path or directory', + outputRequired: true, + examples: [ + [ + 'Extract eight thumbnails', + 'transloadit video thumbs --input demo.mp4 --count 8 --out thumbs/', + ], + [ + 'Resize thumbnails to PNG', + 'transloadit video thumbs --input demo.mp4 --count 5 --format png --width 640 --out thumbs/', + ], + ], + schemaOptions: { + importName: 'robotVideoThumbsInstructionsSchema', + importPath: '../../alphalib/types/robots/video-thumbs.ts', + schema: robotVideoThumbsInstructionsSchema, + keys: ['count', 'format', 'width', 'height', 'resize_strategy', 'background', 'rotate'], + }, + execution: { + kind: 'single-step', + resultStepName: 'thumbnailed', + fixedValues: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + }, + }, + { + className: 'VideoEncodeHlsCommand', + summary: 'Encode videos to HLS', + description: 'Encode a video into an HLS package', + details: + 'Runs the `builtin/encode-hls-video@latest` builtin template and downloads the HLS outputs into `--out`.', + paths: [['video', 'encode-hls']], + input: localFileInput, + outputDescription: 'Write the HLS outputs into this directory', + outputRequired: true, + examples: [ + ['Encode a single video', 'transloadit video encode-hls --input input.mp4 --out dist/hls'], + [ + 'Process a directory recursively', + 'transloadit video encode-hls --input videos/ --out dist/hls --recursive', + ], + ], + execution: { + kind: 'template', + templateId: 'builtin/encode-hls-video@latest', + }, + }, + { + className: 'FileCompressCommand', + summary: 'Compress files into an archive', + description: 'Create an archive from one or more files', + details: + 'Runs `/file/compress` and writes the resulting archive to `--out`. Multiple inputs are bundled into one archive by default.', + paths: [['file', 'compress']], + input: { + kind: 'local-files', + description: 'Provide one or more input files or directories', + recursive: true, + deleteAfterProcessing: true, + reprocessStale: true, + defaultSingleAssembly: true, + }, + outputDescription: 'Write the generated archive to this path', + outputRequired: true, + examples: [ + [ + 'Create a ZIP archive', + 'transloadit file compress --input assets/ --format zip --out assets.zip', + ], + [ + 'Create a gzipped tarball', + 'transloadit file compress --input assets/ --format tar --gzip true --out assets.tar.gz', + ], + ], + schemaOptions: { + importName: 'robotFileCompressInstructionsSchema', + importPath: '../../alphalib/types/robots/file-compress.ts', + schema: robotFileCompressInstructionsSchema, + keys: ['format', 'gzip', 'password', 'compression_level', 'file_layout', 'archive_name'], + }, + execution: { + kind: 'single-step', + resultStepName: 'compressed', + fixedValues: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + }, + }, + { + className: 'FileDecompressCommand', + summary: 'Extract archive contents', + description: 'Decompress an archive', + details: + 'Runs `/file/decompress` on each input archive and writes the extracted files to `--out`.', + paths: [['file', 'decompress']], + input: localFileInput, + outputDescription: 'Write the extracted files to this directory', + outputRequired: true, + examples: [ + [ + 'Decompress a ZIP archive', + 'transloadit file decompress --input assets.zip --out extracted/', + ], + ], + schemaOptions: { + importName: 'robotFileDecompressInstructionsSchema', + importPath: '../../alphalib/types/robots/file-decompress.ts', + schema: robotFileDecompressInstructionsSchema, + keys: [], + }, + execution: { + kind: 'single-step', + resultStepName: 'decompressed', + fixedValues: { + robot: '/file/decompress', + result: true, + use: ':original', + }, + }, + }, +] as const satisfies readonly IntentCommandSpec[] diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts new file mode 100644 index 00000000..495ac512 --- /dev/null +++ b/packages/node/src/cli/intentRuntime.ts @@ -0,0 +1,52 @@ +import type { z } from 'zod' + +export type IntentFieldKind = 'boolean' | 'number' | 'string' + +export interface IntentFieldSpec { + kind: IntentFieldKind + name: string +} + +export function coerceIntentFieldValue( + kind: IntentFieldKind, + raw: string, +): boolean | number | string { + if (kind === 'number') { + const value = Number(raw) + if (Number.isNaN(value)) { + throw new Error(`Expected a number but received "${raw}"`) + } + return value + } + + if (kind === 'boolean') { + if (raw === 'true') return true + if (raw === 'false') return false + throw new Error(`Expected "true" or "false" but received "${raw}"`) + } + + return raw +} + +export function parseIntentStep({ + fieldSpecs, + fixedValues, + rawValues, + schema, +}: { + fieldSpecs: readonly IntentFieldSpec[] + fixedValues: Record + rawValues: Record + schema: TSchema +}): z.input { + const input: Record = { ...fixedValues } + + for (const fieldSpec of fieldSpecs) { + const rawValue = rawValues[fieldSpec.name] + if (rawValue == null) continue + input[fieldSpec.name] = coerceIntentFieldValue(fieldSpec.kind, rawValue) + } + + schema.parse(input) + return input as z.input +} diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts new file mode 100644 index 00000000..c558c3dd --- /dev/null +++ b/packages/node/test/unit/cli/intents.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import * as assembliesCommands from '../../../src/cli/commands/assemblies.ts' +import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' + +const noopWrite = () => true + +const resetExitCode = () => { + process.exitCode = undefined +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + resetExitCode() +}) + +describe('intent commands', () => { + it('maps image generate flags to /image/generate step parameters', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'generate', + '--prompt', + 'A red bicycle in a studio', + '--model', + 'flux-schnell', + '--aspect-ratio', + '2:3', + '--out', + 'generated.png', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [], + output: 'generated.png', + stepsData: { + generated_image: expect.objectContaining({ + robot: '/image/generate', + result: true, + prompt: 'A red bicycle in a studio', + model: 'flux-schnell', + aspect_ratio: '2:3', + }), + }, + }), + ) + }) + + it('maps preview generate flags to /http/import + /file/preview steps', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'preview', + 'generate', + '--input', + 'https://example.com/file.pdf', + '--width', + '320', + '--height', + '200', + '--format', + 'jpg', + '--out', + 'preview.jpg', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [], + output: 'preview.jpg', + stepsData: { + imported: { + robot: '/http/import', + url: 'https://example.com/file.pdf', + }, + preview: expect.objectContaining({ + robot: '/file/preview', + result: true, + use: 'imported', + width: 320, + height: 200, + format: 'jpg', + }), + }, + }), + ) + }) + + it('maps video encode-hls to the builtin template', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main(['video', 'encode-hls', '--input', 'input.mp4', '--out', 'dist/hls', '--recursive']) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + template: 'builtin/encode-hls-video@latest', + inputs: ['input.mp4'], + output: 'dist/hls', + recursive: true, + }), + ) + }) + + it('maps text speak flags to /text/speak step parameters', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'text', + 'speak', + '--prompt', + 'Hello world', + '--provider', + 'aws', + '--target-language', + 'en-US', + '--voice', + 'female-1', + '--out', + 'hello.mp3', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [], + output: 'hello.mp3', + stepsData: { + synthesized: expect.objectContaining({ + robot: '/text/speak', + result: true, + prompt: 'Hello world', + provider: 'aws', + target_language: 'en-US', + voice: 'female-1', + }), + }, + }), + ) + }) + + it('maps file compress to a bundled single assembly by default', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'file', + 'compress', + '--input', + 'assets', + '--format', + 'zip', + '--gzip', + 'true', + '--out', + 'assets.zip', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['assets'], + output: 'assets.zip', + singleAssembly: true, + stepsData: { + compressed: expect.objectContaining({ + robot: '/file/compress', + result: true, + format: 'zip', + gzip: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }), + }, + }), + ) + }) +}) From 3d98ba6e89a451bba611acb4d799fd19ce03f469 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 25 Mar 2026 20:05:16 +0100 Subject: [PATCH 02/44] fix(node-cli): preserve normalized intent values --- packages/node/src/cli/intentRuntime.ts | 4 +- packages/node/test/unit/cli/intents.test.ts | 41 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 495ac512..f4546951 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -47,6 +47,6 @@ export function parseIntentStep({ input[fieldSpec.name] = coerceIntentFieldValue(fieldSpec.kind, rawValue) } - schema.parse(input) - return input as z.input + const parsed = schema.parse(input) + return parsed as z.input } diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index c558c3dd..c75745a1 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -185,6 +185,47 @@ describe('intent commands', () => { ) }) + it('applies schema normalization before submitting generated steps', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'audio', + 'waveform', + '--input', + 'song.mp3', + '--style', + '1', + '--out', + 'waveform.png', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['song.mp3'], + output: 'waveform.png', + stepsData: { + waveformed: expect.objectContaining({ + robot: '/audio/waveform', + result: true, + use: ':original', + style: 'v1', + }), + }, + }), + ) + }) + it('maps file compress to a bundled single assembly by default', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') From 87ec7b9a81561b79bee89b7bd896e53ef58000a0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 25 Mar 2026 20:18:32 +0100 Subject: [PATCH 03/44] fix(node-cli): address council review findings --- .../node/scripts/generate-intent-commands.ts | 86 +++++++---- packages/node/src/cli/commands/assemblies.ts | 35 ++++- .../src/cli/commands/generated-intents.ts | 7 +- packages/node/src/cli/intentCommandSpecs.ts | 5 + .../test/unit/cli/assemblies-create.test.ts | 140 ++++++++++++++++++ packages/node/test/unit/cli/intents.test.ts | 85 +++++++++++ 6 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 packages/node/test/unit/cli/assemblies-create.test.ts diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 50f53455..62fceca0 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -51,6 +51,11 @@ function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { let required = true while (true) { + if (schema instanceof ZodEffects) { + schema = schema._def.schema + continue + } + if (schema instanceof ZodOptional) { required = false schema = schema.unwrap() @@ -73,6 +78,41 @@ function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { } } +function getFieldKind(schema: unknown): GeneratedFieldKind { + if (schema instanceof ZodEffects) { + return getFieldKind(schema._def.schema) + } + + if (schema instanceof ZodString || schema instanceof ZodEnum) { + return 'string' + } + + if (schema instanceof ZodNumber) { + return 'number' + } + + if (schema instanceof ZodBoolean) { + return 'boolean' + } + + if (schema instanceof ZodLiteral) { + if (typeof schema.value === 'number') return 'number' + if (typeof schema.value === 'boolean') return 'boolean' + return 'string' + } + + if (schema instanceof ZodUnion) { + const optionKinds = new Set(schema._def.options.map((option) => getFieldKind(option))) + if (optionKinds.size === 1) { + const [kind] = optionKinds + if (kind != null) return kind + } + return 'string' + } + + throw new Error('Unsupported schema type') +} + function collectSchemaFields(schemaOptions: IntentSchemaOptionSpec): GeneratedSchemaField[] { const shape = (schemaOptions.schema as ZodObject>).shape const requiredKeys = new Set(schemaOptions.requiredKeys ?? []) @@ -89,33 +129,14 @@ function collectSchemaFields(schemaOptions: IntentSchemaOptionSpec): GeneratedSc const description = fieldSchema.description const required = requiredKeys.has(key) || schemaRequired - if (unwrappedSchema instanceof ZodString || unwrappedSchema instanceof ZodEnum) { - return { name: key, propertyName, optionFlags, required, description, kind: 'string' } - } - - if (unwrappedSchema instanceof ZodNumber) { - return { name: key, propertyName, optionFlags, required, description, kind: 'number' } - } - - if (unwrappedSchema instanceof ZodBoolean) { - return { name: key, propertyName, optionFlags, required, description, kind: 'boolean' } - } - - if (unwrappedSchema instanceof ZodEffects) { - const effectInnerSchema = unwrappedSchema._def.schema - const kind: GeneratedFieldKind = effectInnerSchema instanceof ZodNumber ? 'number' : 'string' - return { name: key, propertyName, optionFlags, required, description, kind } + return { + name: key, + propertyName, + optionFlags, + required, + description, + kind: getFieldKind(unwrappedSchema), } - - if (unwrappedSchema instanceof ZodLiteral) { - return { name: key, propertyName, optionFlags, required, description, kind: 'string' } - } - - if (unwrappedSchema instanceof ZodUnion) { - return { name: key, propertyName, optionFlags, required, description, kind: 'string' } - } - - throw new Error(`Unsupported schema type for "${key}"`) }) } @@ -225,9 +246,16 @@ function formatInputOptions(spec: IntentCommandSpec): string { return '' } -function formatLocalCreateOptions(input: IntentInputLocalFilesSpec): string { +function formatLocalCreateOptions( + spec: IntentCommandSpec, + input: IntentInputLocalFilesSpec, +): string { const entries = [' inputs: this.inputs ?? [],', ' output: this.outputPath,'] + if (spec.outputMode != null) { + entries.push(` outputMode: ${JSON.stringify(spec.outputMode)},`) + } + if (input.recursive !== false) { entries.push(' recursive: this.recursive,') } @@ -308,7 +336,7 @@ ${parseStep} stepsData: { ${JSON.stringify(spec.execution.resultStepName)}: step, }, -${formatLocalCreateOptions(spec.input)} +${formatLocalCreateOptions(spec, spec.input)} }) return hasFailures ? 1 : undefined` @@ -361,7 +389,7 @@ ${formatLocalCreateOptions(spec.input)} const { hasFailures } = await assembliesCommands.create(this.output, this.client, { template: ${JSON.stringify(spec.execution.templateId)}, -${formatLocalCreateOptions(spec.input)} +${formatLocalCreateOptions(spec, spec.input)} }) return hasFailures ? 1 : undefined` diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index e0ccac0c..ff636e3a 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -315,6 +315,7 @@ interface StreamRegistry { } interface JobEmitterOptions { + allowOutputCollisions?: boolean recursive?: boolean outstreamProvider: OutstreamProvider streamRegistry: StreamRegistry @@ -747,9 +748,20 @@ function dismissStaleJobs(jobEmitter: EventEmitter): MyEventEmitter { return emitter } +function passthroughJobs(jobEmitter: EventEmitter): MyEventEmitter { + const emitter = new MyEventEmitter() + + jobEmitter.on('end', () => emitter.emit('end')) + jobEmitter.on('error', (err: Error) => emitter.emit('error', err)) + jobEmitter.on('job', (job: Job) => emitter.emit('job', job)) + + return emitter +} + function makeJobEmitter( inputs: string[], { + allowOutputCollisions, recursive, outstreamProvider, streamRegistry, @@ -818,8 +830,10 @@ function makeJobEmitter( emitter.emit('error', err) }) - const stalefilter = reprocessStale ? (x: EventEmitter) => x as MyEventEmitter : dismissStaleJobs - return stalefilter(detectConflicts(emitter)) + const conflictFilter = allowOutputCollisions ? passthroughJobs : detectConflicts + const staleFilter = reprocessStale ? passthroughJobs : dismissStaleJobs + + return staleFilter(conflictFilter(emitter)) } export interface AssembliesCreateOptions { @@ -827,6 +841,7 @@ export interface AssembliesCreateOptions { stepsData?: StepsInput template?: string fields?: Record + outputMode?: 'directory' | 'file' watch?: boolean recursive?: boolean inputs: string[] @@ -848,6 +863,7 @@ export async function create( stepsData, template, fields, + outputMode, watch: watchOption, recursive, inputs, @@ -893,9 +909,19 @@ export async function create( if (resolvedOutput != null) { const [err, stat] = await tryCatch(myStat(process.stdout, resolvedOutput)) if (err && (!isErrnoException(err) || err.code !== 'ENOENT')) throw err - outstat = stat ?? { isDirectory: () => false } + outstat = + stat ?? + ({ + isDirectory: () => outputMode === 'directory', + } satisfies StatLike) + + if (outputMode === 'directory' && stat != null && !stat.isDirectory()) { + const msg = 'Output must be a directory for this command' + outputctl.error(msg) + throw new Error(msg) + } - if (!outstat.isDirectory() && inputs.length !== 0) { + if (!outstat.isDirectory() && inputs.length !== 0 && !singleAssembly) { const firstInput = inputs[0] if (firstInput) { const firstInputStat = await myStat(process.stdin, firstInput) @@ -927,6 +953,7 @@ export async function create( const streamRegistry: StreamRegistry = {} const emitter = makeJobEmitter(inputs, { + allowOutputCollisions: singleAssembly, recursive, watch: watchOption, outstreamProvider, diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index 0932e6f8..c2c454bd 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -1086,6 +1086,7 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'directory', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -1134,7 +1135,6 @@ export class AudioWaveformCommand extends AuthenticatedCommand { style = Option.String('--style', { description: 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', - required: true, }) backgroundColor = Option.String('--background-color', { @@ -1440,7 +1440,7 @@ export class VideoThumbsCommand extends AuthenticatedCommand { { name: 'height', kind: 'number' }, { name: 'resize_strategy', kind: 'string' }, { name: 'background', kind: 'string' }, - { name: 'rotate', kind: 'string' }, + { name: 'rotate', kind: 'number' }, ], rawValues: { count: this.count, @@ -1459,6 +1459,7 @@ export class VideoThumbsCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'directory', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -1537,6 +1538,7 @@ export class VideoEncodeHlsCommand extends AuthenticatedCommand { template: 'builtin/encode-hls-video@latest', inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'directory', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -1747,6 +1749,7 @@ export class FileDecompressCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'directory', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index ed7a9c23..20ae8dc9 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -79,6 +79,7 @@ export interface IntentCommandSpec { examples: Array<[string, string]> execution: IntentExecutionSpec input: IntentInputSpec + outputMode?: 'directory' | 'file' outputDescription: string outputRequired: boolean paths: string[][] @@ -397,6 +398,7 @@ export const intentCommandSpecs = [ 'Runs `/document/thumbs` on each input document and writes the extracted pages or animated GIF to `--out`.', paths: [['document', 'thumbs']], input: localFileInput, + outputMode: 'directory', outputDescription: 'Write the extracted document thumbnails to this path or directory', outputRequired: true, examples: [ @@ -521,6 +523,7 @@ export const intentCommandSpecs = [ details: 'Runs `/video/thumbs` on each input video and writes the extracted images to `--out`.', paths: [['video', 'thumbs']], input: localFileInput, + outputMode: 'directory', outputDescription: 'Write the extracted video thumbnails to this path or directory', outputRequired: true, examples: [ @@ -557,6 +560,7 @@ export const intentCommandSpecs = [ 'Runs the `builtin/encode-hls-video@latest` builtin template and downloads the HLS outputs into `--out`.', paths: [['video', 'encode-hls']], input: localFileInput, + outputMode: 'directory', outputDescription: 'Write the HLS outputs into this directory', outputRequired: true, examples: [ @@ -625,6 +629,7 @@ export const intentCommandSpecs = [ 'Runs `/file/decompress` on each input archive and writes the extracted files to `--out`.', paths: [['file', 'decompress']], input: localFileInput, + outputMode: 'directory', outputDescription: 'Write the extracted files to this directory', outputRequired: true, examples: [ diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts new file mode 100644 index 00000000..921f740a --- /dev/null +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { create } from '../../../src/cli/commands/assemblies.ts' +import OutputCtl from '../../../src/cli/OutputCtl.ts' + +const tempDirs: string[] = [] + +async function createTempDir(prefix: string): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), prefix)) + tempDirs.push(tempDir) + return tempDir +} + +afterEach(async () => { + vi.restoreAllMocks() + nock.cleanAll() + nock.abortPendingRequests() + + await Promise.all( + tempDirs.splice(0).map((tempDir) => rm(tempDir, { recursive: true, force: true })), + ) +}) + +describe('assemblies create', () => { + it('supports bundled single-assembly outputs written to a file path', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-bundle-') + const inputA = path.join(tempDir, 'a.txt') + const inputB = path.join(tempDir, 'b.txt') + const outputPath = path.join(tempDir, 'bundle.zip') + + await writeFile(inputA, 'a') + await writeFile(inputB, 'b') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-1' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + compressed: [{ url: 'http://downloads.test/bundle.zip', name: 'bundle.zip' }], + }, + }), + } + + nock('http://downloads.test').get('/bundle.zip').reply(200, 'bundle-contents') + + await expect( + create(output, client as never, { + inputs: [inputA, inputB], + output: outputPath, + singleAssembly: true, + stepsData: { + compressed: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + expect(client.createAssembly).toHaveBeenCalledTimes(1) + expect(await readFile(outputPath, 'utf8')).toBe('bundle-contents') + }) + + it('treats explicit directory outputs as directories even when the path does not exist yet', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-outdir-') + const inputPath = path.join(tempDir, 'clip.mp4') + const outputDir = path.join(tempDir, 'thumbs') + + await writeFile(inputPath, 'video') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-2' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + thumbs: [ + { url: 'http://downloads.test/one.jpg', name: 'one.jpg' }, + { url: 'http://downloads.test/two.jpg', name: 'two.jpg' }, + ], + }, + }), + } + + nock('http://downloads.test').get('/one.jpg').reply(200, 'one') + nock('http://downloads.test').get('/two.jpg').reply(200, 'two') + + await expect( + create( + output, + client as never, + { + inputs: [inputPath], + output: outputDir, + stepsData: { + thumbs: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + }, + outputMode: 'directory', + } as never, + ), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + let relpath = path.relative(process.cwd(), inputPath) + relpath = relpath.replace(/^(\.\.\/)+/, '') + const resultsDir = path.join( + outputDir, + path.dirname(relpath), + path.parse(relpath).name, + 'thumbs', + ) + + expect(await readFile(path.join(resultsDir, 'one.jpg'), 'utf8')).toBe('one') + expect(await readFile(path.join(resultsDir, 'two.jpg'), 'utf8')).toBe('two') + }) +}) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index c75745a1..01ae70a0 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -185,6 +185,38 @@ describe('intent commands', () => { ) }) + it('allows audio waveform to use the schema default style', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main(['audio', 'waveform', '--input', 'podcast.mp3', '--out', 'waveform.png']) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['podcast.mp3'], + output: 'waveform.png', + stepsData: { + waveformed: expect.objectContaining({ + robot: '/audio/waveform', + result: true, + use: ':original', + style: 'v0', + }), + }, + }), + ) + }) + it('applies schema normalization before submitting generated steps', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -226,6 +258,59 @@ describe('intent commands', () => { ) }) + it('passes directory output intent for multi-file commands', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main(['video', 'thumbs', '--input', 'demo.mp4', '--out', 'thumbs']) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['demo.mp4'], + output: 'thumbs', + outputMode: 'directory', + }), + ) + }) + + it('coerces numeric literal union options like video thumbs --rotate', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main(['video', 'thumbs', '--input', 'demo.mp4', '--rotate', '90', '--out', 'thumbs']) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + thumbnailed: expect.objectContaining({ + robot: '/video/thumbs', + rotate: 90, + }), + }, + }), + ) + }) + it('maps file compress to a bundled single assembly by default', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') From b9cd009180cc7de9e32a23cdd83534d69e1b79d6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 25 Mar 2026 23:09:53 +0100 Subject: [PATCH 04/44] refactor(node-cli): infer intents from minimal catalog --- .../node/scripts/generate-intent-commands.ts | 611 +++++++++++-- .../src/cli/commands/generated-intents.ts | 827 ++++++++++++++---- packages/node/src/cli/intentCommandSpecs.ts | 810 +++++------------ 3 files changed, 1403 insertions(+), 845 deletions(-) diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 62fceca0..a8d04498 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -17,11 +17,17 @@ import { } from 'zod' import type { - IntentCommandSpec, - IntentInputLocalFilesSpec, - IntentSchemaOptionSpec, + IntentCatalogEntry, + IntentInputMode, + IntentOutputMode, + RobotIntentCatalogEntry, + RobotIntentDefinition, +} from '../src/cli/intentCommandSpecs.ts' +import { + intentCatalog, + intentRecipeDefinitions, + robotIntentDefinitions, } from '../src/cli/intentCommandSpecs.ts' -import { intentCommandSpecs } from '../src/cli/intentCommandSpecs.ts' type GeneratedFieldKind = 'boolean' | 'number' | 'string' @@ -34,6 +40,109 @@ interface GeneratedSchemaField { required: boolean } +interface ResolvedIntentLocalFilesInput { + allowConcurrency?: boolean + allowSingleAssembly?: boolean + allowWatch?: boolean + defaultSingleAssembly?: boolean + deleteAfterProcessing?: boolean + description: string + kind: 'local-files' + recursive?: boolean + reprocessStale?: boolean +} + +interface ResolvedIntentNoneInput { + kind: 'none' +} + +interface ResolvedIntentRemoteUrlInput { + description: string + kind: 'remote-url' +} + +type ResolvedIntentInput = + | ResolvedIntentLocalFilesInput + | ResolvedIntentNoneInput + | ResolvedIntentRemoteUrlInput + +interface ResolvedIntentSchemaSpec { + importName: string + importPath: string + schema: ZodObject> +} + +interface ResolvedIntentSingleStepExecution { + fixedValues: Record + kind: 'single-step' + resultStepName: string +} + +interface ResolvedIntentTemplateExecution { + kind: 'template' + templateId: string +} + +interface ResolvedIntentRemotePreviewExecution { + fixedValues: Record + importStepName: string + kind: 'remote-preview' + previewStepName: string +} + +type ResolvedIntentExecution = + | ResolvedIntentRemotePreviewExecution + | ResolvedIntentSingleStepExecution + | ResolvedIntentTemplateExecution + +interface ResolvedIntentCommandSpec { + className: string + description: string + details: string + examples: Array<[string, string]> + execution: ResolvedIntentExecution + input: ResolvedIntentInput + outputDescription: string + outputMode?: IntentOutputMode + outputRequired: boolean + paths: string[] + schemaSpec?: ResolvedIntentSchemaSpec +} + +const hiddenFieldNames = new Set([ + 'ffmpeg_stack', + 'force_accept', + 'ignore_errors', + 'imagemagick_stack', + 'output_meta', + 'queue', + 'result', + 'robot', + 'stack', + 'use', +]) + +const pathAliases = new Map([ + ['autorotate', 'auto-rotate'], + ['bgremove', 'remove-background'], +]) + +const resultStepNameAliases = new Map([ + ['/audio/waveform', 'waveformed'], + ['/document/autorotate', 'autorotated'], + ['/document/convert', 'converted'], + ['/document/optimize', 'optimized'], + ['/document/thumbs', 'thumbnailed'], + ['/file/compress', 'compressed'], + ['/file/decompress', 'decompressed'], + ['/image/bgremove', 'removed_background'], + ['/image/generate', 'generated_image'], + ['/image/optimize', 'optimized'], + ['/image/resize', 'resized'], + ['/text/speak', 'synthesized'], + ['/video/thumbs', 'thumbnailed'], +]) + const __dirname = path.dirname(fileURLToPath(import.meta.url)) const packageRoot = path.resolve(__dirname, '..') const outputPath = path.resolve(__dirname, '../src/cli/commands/generated-intents.ts') @@ -46,6 +155,17 @@ function toKebabCase(value: string): string { return value.replaceAll('_', '-') } +function toPascalCase(parts: string[]): string { + return parts + .flatMap((part) => part.split('-')) + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join('') +} + +function stripTrailingPunctuation(value: string): string { + return value.replace(/[.:]+$/, '').trim() +} + function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { let schema = input let required = true @@ -113,31 +233,368 @@ function getFieldKind(schema: unknown): GeneratedFieldKind { throw new Error('Unsupported schema type') } -function collectSchemaFields(schemaOptions: IntentSchemaOptionSpec): GeneratedSchemaField[] { - const shape = (schemaOptions.schema as ZodObject>).shape - const requiredKeys = new Set(schemaOptions.requiredKeys ?? []) +function inferCommandPathsFromRobot(robot: string): string[] { + const segments = robot.split('/').filter(Boolean) + const [group, action] = segments + if (group == null || action == null) { + throw new Error(`Could not infer command path from robot "${robot}"`) + } + + return [group, pathAliases.get(action) ?? action] +} + +function inferClassName(paths: string[]): string { + return `${toPascalCase(paths)}Command` +} + +function inferInputMode( + entry: RobotIntentCatalogEntry, + definition: RobotIntentDefinition, +): Exclude { + if (entry.inputMode != null) { + return entry.inputMode + } + + const shape = (definition.schema as ZodObject>).shape + if ('prompt' in shape) { + return 'none' + } + + return 'local-files' +} + +function inferOutputMode(entry: IntentCatalogEntry): IntentOutputMode { + return entry.outputMode ?? 'file' +} + +function inferDescription(definition: RobotIntentDefinition): string { + return stripTrailingPunctuation(definition.meta.title) +} + +function inferOutputDescription(inputMode: IntentInputMode, outputMode: IntentOutputMode): string { + if (outputMode === 'directory') { + return 'Write the results to this directory' + } + + if (inputMode === 'local-files') { + return 'Write the result to this path or directory' + } + + return 'Write the result to this path' +} + +function inferDetails( + definition: RobotIntentDefinition, + inputMode: IntentInputMode, + outputMode: IntentOutputMode, + defaultSingleAssembly: boolean, +): string { + if (inputMode === 'none') { + return `Runs \`${definition.robot}\` and writes the result to \`--out\`.` + } - return schemaOptions.keys.map((key) => { - const fieldSchema = shape[key] - if (fieldSchema == null) { - throw new Error(`Schema is missing expected key "${key}"`) + if (defaultSingleAssembly) { + return `Runs \`${definition.robot}\` for the provided inputs and writes the result to \`--out\`.` + } + + if (outputMode === 'directory') { + return `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` + } + + return `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.` +} + +function inferLocalFilesInput(entry: RobotIntentCatalogEntry): ResolvedIntentLocalFilesInput { + if (entry.defaultSingleAssembly) { + return { + kind: 'local-files', + description: 'Provide one or more input files or directories', + recursive: true, + deleteAfterProcessing: true, + reprocessStale: true, + defaultSingleAssembly: true, } + } + + return { + kind: 'local-files', + description: 'Provide an input file or a directory', + recursive: true, + allowWatch: true, + deleteAfterProcessing: true, + reprocessStale: true, + allowSingleAssembly: true, + allowConcurrency: true, + } +} + +function inferInputSpec( + entry: RobotIntentCatalogEntry, + definition: RobotIntentDefinition, +): ResolvedIntentInput { + const inputMode = inferInputMode(entry, definition) + if (inputMode === 'none') { + return { kind: 'none' } + } + + return inferLocalFilesInput(entry) +} - const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) - const propertyName = toCamelCase(key) - const optionFlags = `--${toKebabCase(key)}` - const description = fieldSchema.description - const required = requiredKeys.has(key) || schemaRequired +function inferFixedValues( + entry: RobotIntentCatalogEntry, + definition: RobotIntentDefinition, + inputMode: Exclude, +): Record { + if (entry.defaultSingleAssembly) { + return { + robot: definition.robot, + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + } + } + if (inputMode === 'local-files') { return { - name: key, - propertyName, - optionFlags, - required, - description, - kind: getFieldKind(unwrappedSchema), + robot: definition.robot, + result: true, + use: ':original', } - }) + } + + return { + robot: definition.robot, + result: true, + } +} + +function inferResultStepName(robot: string): string { + return resultStepNameAliases.get(robot) ?? inferCommandPathsFromRobot(robot)[1] +} + +function guessInputFile(meta: RobotMetaInput): string { + switch (meta.typical_file_type) { + case 'audio file': + return 'input.mp3' + case 'document': + return 'input.pdf' + case 'image': + return 'input.png' + case 'video': + return 'input.mp4' + default: + return 'input.file' + } +} + +function guessOutputPath( + definition: RobotIntentDefinition | null, + paths: string[], + outputMode: IntentOutputMode, +): string { + if (outputMode === 'directory') { + return 'output/' + } + + const [group] = paths + if (definition?.robot === '/file/compress') { + return 'archive.zip' + } + + if (group === 'audio') { + return 'output.png' + } + + if (group === 'document') { + return 'output.pdf' + } + + if (group === 'image') { + return 'output.png' + } + + if (group === 'text') { + return 'output.mp3' + } + + return 'output.file' +} + +function guessPromptExample(robot: string): string { + if (robot === '/image/generate') { + return 'A red bicycle in a studio' + } + + return 'Hello world' +} + +function inferExamples( + definition: RobotIntentDefinition | null, + paths: string[], + inputMode: IntentInputMode, + outputMode: IntentOutputMode, +): Array<[string, string]> { + const parts = ['transloadit', ...paths] + + if (inputMode === 'local-files' && definition != null) { + parts.push('--input', guessInputFile(definition.meta)) + } + + if (inputMode === 'none' && definition != null) { + parts.push('--prompt', JSON.stringify(guessPromptExample(definition.robot))) + } + + if (inputMode === 'remote-url') { + parts.push('--input', 'https://example.com/file.pdf') + } + + parts.push('--out', guessOutputPath(definition, paths, outputMode)) + + return [['Run the command', parts.join(' ')]] +} + +function collectSchemaFields( + schemaSpec: ResolvedIntentSchemaSpec, + fixedValues: Record, + input: ResolvedIntentInput, +): GeneratedSchemaField[] { + const shape = (schemaSpec.schema as ZodObject>).shape + + return Object.entries(shape) + .filter(([key]) => !hiddenFieldNames.has(key) && !Object.hasOwn(fixedValues, key)) + .flatMap(([key, fieldSchema]) => { + const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) + + let kind: GeneratedFieldKind + try { + kind = getFieldKind(unwrappedSchema) + } catch { + return [] + } + + const required = (input.kind === 'none' && key === 'prompt') || schemaRequired + + return [ + { + name: key, + propertyName: toCamelCase(key), + optionFlags: `--${toKebabCase(key)}`, + required, + description: fieldSchema.description, + kind, + }, + ] + }) +} + +function resolveRobotIntentSpec(entry: RobotIntentCatalogEntry): ResolvedIntentCommandSpec { + const definition = robotIntentDefinitions[entry.robot] + if (definition == null) { + throw new Error(`No robot intent definition found for "${entry.robot}"`) + } + + const paths = inferCommandPathsFromRobot(definition.robot) + const inputMode = inferInputMode(entry, definition) + const outputMode = inferOutputMode(entry) + const input = inferInputSpec(entry, definition) + + return { + className: inferClassName(paths), + description: inferDescription(definition), + details: inferDetails(definition, inputMode, outputMode, entry.defaultSingleAssembly === true), + examples: inferExamples(definition, paths, inputMode, outputMode), + input, + outputDescription: inferOutputDescription(inputMode, outputMode), + outputMode, + outputRequired: true, + paths, + schemaSpec: { + importName: definition.schemaImportName, + importPath: definition.schemaImportPath, + schema: definition.schema as ZodObject>, + }, + execution: { + kind: 'single-step', + resultStepName: inferResultStepName(definition.robot), + fixedValues: inferFixedValues(entry, definition, inputMode), + }, + } +} + +function resolveTemplateIntentSpec( + entry: IntentCatalogEntry & { kind: 'template' }, +): ResolvedIntentCommandSpec { + const outputMode = inferOutputMode(entry) + const input = inferLocalFilesInput({ kind: 'robot', robot: '/file/decompress', outputMode }) + + return { + className: inferClassName(entry.paths), + description: `Run ${stripTrailingPunctuation(entry.templateId)}`, + details: `Runs the \`${entry.templateId}\` template and writes the outputs to \`--out\`.`, + examples: [ + ['Run the command', `transloadit ${entry.paths.join(' ')} --input input.mp4 --out output/`], + ], + execution: { + kind: 'template', + templateId: entry.templateId, + }, + input, + outputDescription: inferOutputDescription('local-files', outputMode), + outputMode, + outputRequired: true, + paths: entry.paths, + } +} + +function resolveRecipeIntentSpec( + entry: IntentCatalogEntry & { kind: 'recipe' }, +): ResolvedIntentCommandSpec { + const definition = intentRecipeDefinitions[entry.recipe] + if (definition == null) { + throw new Error(`No intent recipe definition found for "${entry.recipe}"`) + } + + return { + className: inferClassName(definition.paths), + description: definition.description, + details: definition.details, + examples: definition.examples, + execution: { + kind: 'remote-preview', + importStepName: 'imported', + previewStepName: definition.resultStepName, + fixedValues: { + robot: '/file/preview', + result: true, + }, + }, + input: { + kind: 'remote-url', + description: 'Remote URL to preview', + }, + outputDescription: definition.outputDescription, + outputRequired: definition.outputRequired, + paths: definition.paths, + schemaSpec: { + importName: definition.schemaImportName, + importPath: definition.schemaImportPath, + schema: definition.schema as ZodObject>, + }, + } +} + +function resolveIntentCommandSpec(entry: IntentCatalogEntry): ResolvedIntentCommandSpec { + if (entry.kind === 'robot') { + return resolveRobotIntentSpec(entry) + } + + if (entry.kind === 'template') { + return resolveTemplateIntentSpec(entry) + } + + return resolveRecipeIntentSpec(entry) } function formatDescription(description: string | undefined): string { @@ -184,7 +641,7 @@ ${fieldSpecs ]` } -function formatLocalInputOptions(input: IntentInputLocalFilesSpec): string { +function formatLocalInputOptions(input: ResolvedIntentLocalFilesInput): string { const blocks = [ ` inputs = Option.Array('--input,-i', { description: ${JSON.stringify(input.description)}, @@ -231,7 +688,7 @@ function formatLocalInputOptions(input: IntentInputLocalFilesSpec): string { return blocks.join('\n\n') } -function formatInputOptions(spec: IntentCommandSpec): string { +function formatInputOptions(spec: ResolvedIntentCommandSpec): string { if (spec.input.kind === 'local-files') { return formatLocalInputOptions(spec.input) } @@ -246,39 +703,40 @@ function formatInputOptions(spec: IntentCommandSpec): string { return '' } -function formatLocalCreateOptions( - spec: IntentCommandSpec, - input: IntentInputLocalFilesSpec, -): string { +function formatLocalCreateOptions(spec: ResolvedIntentCommandSpec): string { + if (spec.input.kind !== 'local-files') { + throw new Error('Expected a local-files input spec') + } + const entries = [' inputs: this.inputs ?? [],', ' output: this.outputPath,'] if (spec.outputMode != null) { entries.push(` outputMode: ${JSON.stringify(spec.outputMode)},`) } - if (input.recursive !== false) { + if (spec.input.recursive !== false) { entries.push(' recursive: this.recursive,') } - if (input.allowWatch) { + if (spec.input.allowWatch) { entries.push(' watch: this.watch,') } - if (input.deleteAfterProcessing !== false) { + if (spec.input.deleteAfterProcessing !== false) { entries.push(' del: this.deleteAfterProcessing,') } - if (input.reprocessStale !== false) { + if (spec.input.reprocessStale !== false) { entries.push(' reprocessStale: this.reprocessStale,') } - if (input.allowSingleAssembly) { + if (spec.input.allowSingleAssembly) { entries.push(' singleAssembly: this.singleAssembly,') - } else if (input.defaultSingleAssembly) { + } else if (spec.input.defaultSingleAssembly) { entries.push(' singleAssembly: true,') } - if (input.allowConcurrency) { + if (spec.input.allowConcurrency) { entries.push( ' concurrency: this.concurrency == null ? undefined : Number(this.concurrency),', ) @@ -287,7 +745,11 @@ function formatLocalCreateOptions( return entries.join('\n') } -function formatLocalValidation(input: IntentInputLocalFilesSpec, commandLabel: string): string { +function formatLocalValidation(spec: ResolvedIntentCommandSpec, commandLabel: string): string { + if (spec.input.kind !== 'local-files') { + throw new Error('Expected a local-files input spec') + } + const lines = [ ' if ((this.inputs ?? []).length === 0) {', ` this.output.error('${commandLabel} requires at least one --input')`, @@ -295,7 +757,7 @@ function formatLocalValidation(input: IntentInputLocalFilesSpec, commandLabel: s ' }', ] - if (input.allowWatch && input.allowSingleAssembly) { + if (spec.input.allowWatch && spec.input.allowSingleAssembly) { lines.push( '', ' if (this.singleAssembly && this.watch) {', @@ -305,7 +767,7 @@ function formatLocalValidation(input: IntentInputLocalFilesSpec, commandLabel: s ) } - if (input.allowWatch && input.defaultSingleAssembly) { + if (spec.input.allowWatch && spec.input.defaultSingleAssembly) { lines.push( '', ' if (this.watch) {', @@ -318,17 +780,21 @@ function formatLocalValidation(input: IntentInputLocalFilesSpec, commandLabel: s return lines.join('\n') } -function formatRunBody(spec: IntentCommandSpec, fieldSpecs: GeneratedSchemaField[]): string { +function formatRunBody(spec: ResolvedIntentCommandSpec): string { + const schemaSpec = spec.schemaSpec + const fieldSpecs = + schemaSpec == null ? [] : collectSchemaFields(schemaSpec, resolveFixedValues(spec), spec.input) + if (spec.execution.kind === 'single-step') { const parseStep = ` const step = parseIntentStep({ - schema: ${spec.schemaOptions?.importName}, + schema: ${schemaSpec?.importName}, fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ')}, fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, rawValues: ${formatRawValues(fieldSpecs)}, })` if (spec.input.kind === 'local-files') { - return `${formatLocalValidation(spec.input, spec.paths[0].join(' '))} + return `${formatLocalValidation(spec, spec.paths.join(' '))} ${parseStep} @@ -336,7 +802,7 @@ ${parseStep} stepsData: { ${JSON.stringify(spec.execution.resultStepName)}: step, }, -${formatLocalCreateOptions(spec, spec.input)} +${formatLocalCreateOptions(spec)} }) return hasFailures ? 1 : undefined` @@ -356,12 +822,14 @@ ${formatLocalCreateOptions(spec, spec.input)} } if (spec.execution.kind === 'remote-preview') { - return ` const previewStep = parseIntentStep({ - schema: ${spec.schemaOptions?.importName}, + const parseStep = ` const previewStep = parseIntentStep({ + schema: ${schemaSpec?.importName}, fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ')}, fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, rawValues: ${formatRawValues(fieldSpecs)}, - }) + })` + + return `${parseStep} const { hasFailures } = await assembliesCommands.create(this.output, this.client, { stepsData: { @@ -385,22 +853,34 @@ ${formatLocalCreateOptions(spec, spec.input)} throw new Error(`Template command ${spec.className} requires local-files input`) } - return `${formatLocalValidation(spec.input, spec.paths[0].join(' '))} + return `${formatLocalValidation(spec, spec.paths.join(' '))} const { hasFailures } = await assembliesCommands.create(this.output, this.client, { template: ${JSON.stringify(spec.execution.templateId)}, -${formatLocalCreateOptions(spec, spec.input)} +${formatLocalCreateOptions(spec)} }) return hasFailures ? 1 : undefined` } -function generateImports(): string { +function resolveFixedValues(spec: ResolvedIntentCommandSpec): Record { + if (spec.execution.kind === 'single-step') { + return spec.execution.fixedValues + } + + if (spec.execution.kind === 'remote-preview') { + return spec.execution.fixedValues + } + + return {} +} + +function generateImports(specs: ResolvedIntentCommandSpec[]): string { const imports = new Map() - for (const spec of intentCommandSpecs) { - if (!spec.schemaOptions) continue - imports.set(spec.schemaOptions.importName, spec.schemaOptions.importPath) + for (const spec of specs) { + if (spec.schemaSpec == null) continue + imports.set(spec.schemaSpec.importName, spec.schemaSpec.importPath) } return [...imports.entries()] @@ -409,20 +889,23 @@ function generateImports(): string { .join('\n') } -function generateClass(spec: IntentCommandSpec): string { - const fieldSpecs = spec.schemaOptions == null ? [] : collectSchemaFields(spec.schemaOptions) +function generateClass(spec: ResolvedIntentCommandSpec): string { + const fieldSpecs = + spec.schemaSpec == null + ? [] + : collectSchemaFields(spec.schemaSpec, resolveFixedValues(spec), spec.input) const schemaFields = formatSchemaFields(fieldSpecs) const inputOptions = formatInputOptions(spec) - const runBody = formatRunBody(spec, fieldSpecs) + const runBody = formatRunBody(spec) return ` export class ${spec.className} extends AuthenticatedCommand { - static override paths = ${JSON.stringify(spec.paths)} + static override paths = ${JSON.stringify([spec.paths])} static override usage = Command.Usage({ category: 'Intent Commands', description: ${JSON.stringify(spec.description)}, - details: ${JSON.stringify(spec.details ?? '')}, + details: ${JSON.stringify(spec.details)}, examples: [ ${formatUsageExamples(spec.examples)} ], @@ -442,9 +925,9 @@ ${runBody} ` } -function generateFile(): string { - const commandClasses = intentCommandSpecs.map(generateClass) - const commandNames = intentCommandSpecs.map((spec) => spec.className) +function generateFile(specs: ResolvedIntentCommandSpec[]): string { + const commandClasses = specs.map(generateClass) + const commandNames = specs.map((spec) => spec.className) return `// DO NOT EDIT BY HAND. // Generated by \`packages/node/scripts/generate-intent-commands.ts\`. @@ -452,10 +935,10 @@ function generateFile(): string { import { Command, Option } from 'clipanion' import * as t from 'typanion' -${generateImports()} +${generateImports(specs)} +import { parseIntentStep } from '../intentRuntime.ts' import * as assembliesCommands from './assemblies.ts' import { AuthenticatedCommand } from './BaseCommand.ts' -import { parseIntentStep } from '../intentRuntime.ts' ${commandClasses.join('\n')} export const intentCommands = [ ${commandNames.map((name) => ` ${name},`).join('\n')} @@ -464,8 +947,10 @@ ${commandNames.map((name) => ` ${name},`).join('\n')} } async function main(): Promise { + const resolvedSpecs = intentCatalog.map(resolveIntentCommandSpec) + await mkdir(path.dirname(outputPath), { recursive: true }) - await writeFile(outputPath, generateFile()) + await writeFile(outputPath, generateFile(resolvedSpecs)) await execa( 'yarn', ['exec', 'biome', 'check', '--write', path.relative(packageRoot, outputPath)], diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index c2c454bd..373e9f3f 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -27,30 +27,25 @@ export class ImageGenerateCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Generate an image from a prompt', - details: - 'Creates a one-off assembly around `/image/generate` and downloads the result to `--out`.', + description: 'Generate images from text prompts', + details: 'Runs `/image/generate` and writes the result to `--out`.', examples: [ [ - 'Generate a PNG image', - 'transloadit image generate --prompt "A red bicycle in a studio" --out bicycle.png', - ], - [ - 'Pick a model and aspect ratio', - 'transloadit image generate --prompt "An astronaut riding a horse" --model flux-schnell --aspect-ratio 2:3 --out horse.png', + 'Run the command', + 'transloadit image generate --prompt "A red bicycle in a studio" --out output.png', ], ], }) + model = Option.String('--model', { + description: 'The AI model to use for image generation. Defaults to google/nano-banana.', + }) + prompt = Option.String('--prompt', { description: 'The prompt describing the desired image content.', required: true, }) - model = Option.String('--model', { - description: 'The AI model to use for image generation. Defaults to google/nano-banana.', - }) - format = Option.String('--format', { description: 'Format of the generated image.', }) @@ -75,8 +70,12 @@ export class ImageGenerateCommand extends AuthenticatedCommand { description: 'Style of the generated image.', }) + numOutputs = Option.String('--num-outputs', { + description: 'Number of image variants to generate.', + }) + outputPath = Option.String('--out,-o', { - description: 'Write the generated image to this path', + description: 'Write the result to this path', required: true, }) @@ -88,24 +87,26 @@ export class ImageGenerateCommand extends AuthenticatedCommand { result: true, }, fieldSpecs: [ - { name: 'prompt', kind: 'string' }, { name: 'model', kind: 'string' }, + { name: 'prompt', kind: 'string' }, { name: 'format', kind: 'string' }, { name: 'seed', kind: 'number' }, { name: 'aspect_ratio', kind: 'string' }, { name: 'height', kind: 'number' }, { name: 'width', kind: 'number' }, { name: 'style', kind: 'string' }, + { name: 'num_outputs', kind: 'number' }, ], rawValues: { - prompt: this.prompt, model: this.model, + prompt: this.prompt, format: this.format, seed: this.seed, aspect_ratio: this.aspectRatio, height: this.height, width: this.width, style: this.style, + num_outputs: this.numOutputs, }, }) @@ -134,10 +135,6 @@ export class PreviewGenerateCommand extends AuthenticatedCommand { 'Preview a remote PDF', 'transloadit preview generate --input https://example.com/file.pdf --width 300 --height 200 --out preview.png', ], - [ - 'Pick a format and resize strategy', - 'transloadit preview generate --input https://example.com/file.mp4 --width 640 --height 360 --format jpg --resize-strategy fillcrop --out preview.jpg', - ], ], }) @@ -159,6 +156,99 @@ export class PreviewGenerateCommand extends AuthenticatedCommand { 'To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. This happens, for example, when the dimensions of a frame extracted from a video do not match the chosen `width` and `height` parameters.\n\nSee the list of available [resize strategies](/docs/topics/resize-strategies/) for more details.', }) + background = Option.String('--background', { + description: + 'The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). The format is `#rrggbb[aa]` (red, green, blue, alpha). Use `#00000000` for a transparent padding.', + }) + + artworkOuterColor = Option.String('--artwork-outer-color', { + description: "The color used in the outer parts of the artwork's gradient.", + }) + + artworkCenterColor = Option.String('--artwork-center-color', { + description: "The color used in the center of the artwork's gradient.", + }) + + waveformCenterColor = Option.String('--waveform-center-color', { + description: + "The color used in the center of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", + }) + + waveformOuterColor = Option.String('--waveform-outer-color', { + description: + "The color used in the outer parts of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", + }) + + waveformHeight = Option.String('--waveform-height', { + description: + 'Height of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', + }) + + waveformWidth = Option.String('--waveform-width', { + description: + 'Width of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', + }) + + iconStyle = Option.String('--icon-style', { + description: + 'The style of the icon generated if the `icon` strategy is applied. The default style, `with-text`, includes an icon showing the file type and a text box below it, whose content can be controlled by the `icon_text_content` parameter and defaults to the file extension (e.g. MP4, JPEG). The `square` style only includes a square variant of the icon showing the file type. Below are exemplary previews generated for a text file utilizing the different styles:\n\n

`with-text` style:
\n![Image with text style]({{site.asset_cdn}}/assets/images/file-preview/icon-with-text.png)\n

`square` style:
\n![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png)', + }) + + iconTextColor = Option.String('--icon-text-color', { + description: + 'The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied.', + }) + + iconTextFont = Option.String('--icon-text-font', { + description: + 'The font family of the text used in the icon. Only used if the `icon` strategy is applied. [Here](/docs/supported-formats/fonts/) is a list of all supported fonts.', + }) + + iconTextContent = Option.String('--icon-text-content', { + description: + 'The content of the text box in generated icons. Only used if the `icon_style` parameter is set to `with-text`. The default value, `extension`, adds the file extension (e.g. MP4, JPEG) to the icon. The value `none` can be used to render an empty text box, which is useful if no text should not be included in the raster image, but some place should be reserved in the image for later overlaying custom text over the image using HTML etc.', + }) + + optimize = Option.String('--optimize', { + description: + "Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. If enabled, the images will be optimized using [🤖/image/optimize](/docs/robots/image-optimize/).", + }) + + optimizePriority = Option.String('--optimize-priority', { + description: + 'Specifies whether conversion speed or compression ratio is prioritized when optimizing images. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-priority) for more details.', + }) + + optimizeProgressive = Option.String('--optimize-progressive', { + description: + 'Specifies whether images should be interlaced, which makes the result image load progressively in browsers. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-progressive) for more details.', + }) + + clipFormat = Option.String('--clip-format', { + description: + 'The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied.\n\nPlease consult the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types) for detailed information about the image formats and their characteristics. GIF enjoys the broadest support in software, but only supports a limit color palette. APNG supports a variety of color depths, but its lossless compression produces large images for videos. AVIF is a modern image format that offers great compression, but proper support for animations is still lacking in some browsers. WebP on the other hand, enjoys broad support while offering a great balance between small file sizes and good visual quality, making it the default clip format.', + }) + + clipOffset = Option.String('--clip-offset', { + description: + 'The start position in seconds of where the clip is cut. Only used if the `clip` strategy for video files is applied. Be aware that for larger video only the first few MBs of the file may be imported to improve speed. Larger offsets may seek to a position outside of the imported part and thus fail to generate a clip.', + }) + + clipDuration = Option.String('--clip-duration', { + description: + 'The duration in seconds of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a longer clip duration also results in a larger file size, which might be undesirable for previews.', + }) + + clipFramerate = Option.String('--clip-framerate', { + description: + 'The framerate of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a higher framerate appears smoother but also results in a larger file size, which might be undesirable for previews.', + }) + + clipLoop = Option.String('--clip-loop', { + description: + 'Specifies whether the generated animated image should loop forever (`true`) or stop after playing the animation once (`false`). Only used if the `clip` strategy for video files is applied.', + }) + input = Option.String('--input,-i', { description: 'Remote URL to preview', required: true, @@ -181,12 +271,50 @@ export class PreviewGenerateCommand extends AuthenticatedCommand { { name: 'width', kind: 'number' }, { name: 'height', kind: 'number' }, { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'artwork_outer_color', kind: 'string' }, + { name: 'artwork_center_color', kind: 'string' }, + { name: 'waveform_center_color', kind: 'string' }, + { name: 'waveform_outer_color', kind: 'string' }, + { name: 'waveform_height', kind: 'number' }, + { name: 'waveform_width', kind: 'number' }, + { name: 'icon_style', kind: 'string' }, + { name: 'icon_text_color', kind: 'string' }, + { name: 'icon_text_font', kind: 'string' }, + { name: 'icon_text_content', kind: 'string' }, + { name: 'optimize', kind: 'boolean' }, + { name: 'optimize_priority', kind: 'string' }, + { name: 'optimize_progressive', kind: 'boolean' }, + { name: 'clip_format', kind: 'string' }, + { name: 'clip_offset', kind: 'number' }, + { name: 'clip_duration', kind: 'number' }, + { name: 'clip_framerate', kind: 'number' }, + { name: 'clip_loop', kind: 'boolean' }, ], rawValues: { format: this.format, width: this.width, height: this.height, resize_strategy: this.resizeStrategy, + background: this.background, + artwork_outer_color: this.artworkOuterColor, + artwork_center_color: this.artworkCenterColor, + waveform_center_color: this.waveformCenterColor, + waveform_outer_color: this.waveformOuterColor, + waveform_height: this.waveformHeight, + waveform_width: this.waveformWidth, + icon_style: this.iconStyle, + icon_text_color: this.iconTextColor, + icon_text_font: this.iconTextFont, + icon_text_content: this.iconTextContent, + optimize: this.optimize, + optimize_priority: this.optimizePriority, + optimize_progressive: this.optimizeProgressive, + clip_format: this.clipFormat, + clip_offset: this.clipOffset, + clip_duration: this.clipDuration, + clip_framerate: this.clipFramerate, + clip_loop: this.clipLoop, }, }) @@ -214,17 +342,10 @@ export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Remove the background from an image', - details: 'Runs `/image/bgremove` on each input image and downloads the result to `--out`.', + description: 'Remove the background from images', + details: 'Runs `/image/bgremove` on each input file and writes the result to `--out`.', examples: [ - [ - 'Remove the background from one image', - 'transloadit image remove-background --input portrait.png --out portrait-cutout.png', - ], - [ - 'Choose the output format', - 'transloadit image remove-background --input portrait.png --format webp --out portrait-cutout.webp', - ], + ['Run the command', 'transloadit image remove-background --input input.png --out output.png'], ], }) @@ -275,7 +396,7 @@ export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the background-removed image to this path or directory', + description: 'Write the result to this path or directory', required: true, }) @@ -317,6 +438,7 @@ export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -334,17 +456,10 @@ export class ImageOptimizeCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Optimize image file size', - details: 'Runs `/image/optimize` on each input image and downloads the result to `--out`.', + description: 'Optimize images without quality loss', + details: 'Runs `/image/optimize` on each input file and writes the result to `--out`.', examples: [ - [ - 'Optimize a single image', - 'transloadit image optimize --input hero.jpg --out hero-optimized.jpg', - ], - [ - 'Prioritize compression ratio', - 'transloadit image optimize --input hero.jpg --priority compression-ratio --out hero-optimized.jpg', - ], + ['Run the command', 'transloadit image optimize --input input.png --out output.png'], ], }) @@ -398,7 +513,7 @@ export class ImageOptimizeCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the optimized image to this path or directory', + description: 'Write the result to this path or directory', required: true, }) @@ -440,6 +555,7 @@ export class ImageOptimizeCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -457,18 +573,9 @@ export class ImageResizeCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Resize an image', - details: 'Runs `/image/resize` on each input image and downloads the result to `--out`.', - examples: [ - [ - 'Resize an image to 800×600', - 'transloadit image resize --input photo.jpg --width 800 --height 600 --out photo-resized.jpg', - ], - [ - 'Pad with a transparent background', - 'transloadit image resize --input logo.png --width 512 --height 512 --resize-strategy pad --background none --out logo-square.png', - ], - ], + description: 'Convert, resize, or watermark images', + details: 'Runs `/image/resize` on each input file and writes the result to `--out`.', + examples: [['Run the command', 'transloadit image resize --input input.png --out output.png']], }) format = Option.String('--format', { @@ -490,16 +597,188 @@ export class ImageResizeCommand extends AuthenticatedCommand { description: 'See the list of available [resize strategies](/docs/topics/resize-strategies/).', }) + zoom = Option.String('--zoom', { + description: + 'If this is set to `false`, smaller images will not be stretched to the desired width and height. For details about the impact of zooming for your preferred resize strategy, see the list of available [resize strategies](/docs/topics/resize-strategies/).', + }) + + gravity = Option.String('--gravity', { + description: + 'The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined.', + }) + strip = Option.String('--strip', { description: 'Strips all metadata from the image. This is useful to keep thumbnails as small as possible.', }) + alpha = Option.String('--alpha', { + description: 'Gives control of the alpha/matte channel of an image.', + }) + + preclipAlpha = Option.String('--preclip-alpha', { + description: + 'Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`.', + }) + + flatten = Option.String('--flatten', { + description: + 'Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#layers).\n\nTo preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter.', + }) + + correctGamma = Option.String('--correct-gamma', { + description: + 'Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html).', + }) + + quality = Option.String('--quality', { + description: + 'Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', + }) + + adaptiveFiltering = Option.String('--adaptive-filtering', { + description: + 'Controls the image compression for PNG images. Setting to `true` results in smaller file size, while increasing processing time. It is encouraged to keep this option disabled.', + }) + background = Option.String('--background', { description: 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (used for the `pad` resize strategy).\n\n**Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`.', }) + frame = Option.String('--frame', { + description: + 'Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. Specify `1` to use the first frame, `2` to use the second, and so on. `null` means all frames.', + }) + + colorspace = Option.String('--colorspace', { + description: + 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace). Please note that if you were using `"RGB"`, we recommend using `"sRGB"` instead as of 2014-02-04. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might have to use this parameter in combination with `type: "TrueColor"`.', + }) + + type = Option.String('--type', { + description: + 'Sets the image color type. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#type). If you\'re using `colorspace`, ImageMagick might try to find the most efficient based on the color of an image, and default to e.g. `"Gray"`. To force colors, you could e.g. set this parameter to `"TrueColor"`', + }) + + sepia = Option.String('--sepia', { + description: 'Applies a sepia tone effect in percent.', + }) + + rotation = Option.String('--rotation', { + description: + 'Determines whether the image should be rotated. Use any number to specify the rotation angle in degrees (e.g., `90`, `180`, `270`, `360`, or precise values like `2.9`). Use the value `true` or `"auto"` to auto-rotate images that are rotated incorrectly or depend on EXIF rotation settings. Otherwise, use `false` to disable auto-fixing altogether.', + }) + + compress = Option.String('--compress', { + description: + 'Specifies pixel compression for when the image is written. Compression is disabled by default.\n\nPlease also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', + }) + + blur = Option.String('--blur', { + description: + 'Specifies gaussian blur, using a value with the form `{radius}x{sigma}`. The radius value specifies the size of area the operator should look at when spreading pixels, and should typically be either `"0"` or at least two times the sigma value. The sigma value is an approximation of how many pixels the image is "spread"; think of it as the size of the brush used to blur the image. This number is a floating point value, enabling small values like `"0.5"` to be used.', + }) + + brightness = Option.String('--brightness', { + description: + 'Increases or decreases the brightness of the image by using a multiplier. For example `1.5` would increase the brightness by 50%, and `0.75` would decrease the brightness by 25%.', + }) + + saturation = Option.String('--saturation', { + description: + 'Increases or decreases the saturation of the image by using a multiplier. For example `1.5` would increase the saturation by 50%, and `0.75` would decrease the saturation by 25%.', + }) + + hue = Option.String('--hue', { + description: + 'Changes the hue by rotating the color of the image. The value `100` would produce no change whereas `0` and `200` will negate the colors in the image.', + }) + + contrast = Option.String('--contrast', { + description: + 'Adjusts the contrast of the image. A value of `1` produces no change. Values below `1` decrease contrast (with `0` being minimum contrast), and values above `1` increase contrast (with `2` being maximum contrast). This works like the `brightness` parameter.', + }) + + watermarkUrl = Option.String('--watermark-url', { + description: + 'A URL indicating a PNG image to be overlaid above this image. Please note that you can also [supply the watermark via another Assembly Step](/docs/topics/use-parameter/#supplying-the-watermark-via-an-assembly-step). With watermarking you can add an image onto another image. This is usually used for logos.', + }) + + watermarkXOffset = Option.String('--watermark-x-offset', { + description: + "The x-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", + }) + + watermarkYOffset = Option.String('--watermark-y-offset', { + description: + "The y-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", + }) + + watermarkSize = Option.String('--watermark-size', { + description: + 'The size of the watermark, as a percentage.\n\nFor example, a value of `"50%"` means that size of the watermark will be 50% of the size of image on which it is placed. The exact sizing depends on `watermark_resize_strategy`, too.', + }) + + watermarkResizeStrategy = Option.String('--watermark-resize-strategy', { + description: + 'Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`.\n\nTo explain how the resize strategies work, let\'s assume our target image size is 800×800 pixels and our watermark image is 400×300 pixels. Let\'s also assume, the `watermark_size` parameter is set to `"25%"`.\n\nFor the `"fit"` resize strategy, the watermark is scaled so that the longer side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the width is the longer side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 200×150 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 400×300 pixels (so just left at its original size).\n\nFor the `"min_fit"` resize strategy, the watermark is scaled so that the shorter side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the height is the shorter side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 267×200 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 533×400 pixels (so larger than its original size).\n\nFor the `"stretch"` resize strategy, the watermark is stretched (meaning, it is resized without keeping its aspect ratio in mind) so that both sides take up 25% of the corresponding image side. Since our image is 800×800 pixels, for a watermark size of 25% the watermark would be resized to 200×200 pixels. Its height would appear stretched, because keeping the aspect ratio in mind it would be resized to 200×150 pixels instead.\n\nFor the `"area"` resize strategy, the watermark is resized (keeping its aspect ratio in check) so that it covers `"xx%"` of the image\'s surface area. The value from `watermark_size` is used for the percentage area size.', + }) + + watermarkOpacity = Option.String('--watermark-opacity', { + description: + 'The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque.\n\nFor example, a value of `0.5` means the watermark will be 50% transparent, allowing the underlying image to show through. This is useful for subtle branding or when you want the watermark to be less obtrusive.', + }) + + watermarkRepeatX = Option.String('--watermark-repeat-x', { + description: + 'When set to `true`, the watermark will be repeated horizontally across the entire width of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark.', + }) + + watermarkRepeatY = Option.String('--watermark-repeat-y', { + description: + 'When set to `true`, the watermark will be repeated vertically across the entire height of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image. Can be combined with `watermark_repeat_x` to tile in both directions.', + }) + + progressive = Option.String('--progressive', { + description: + 'Interlaces the image if set to `true`, which makes the image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the images which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a cost of a file size increase by around 10%.', + }) + + transparent = Option.String('--transparent', { + description: 'Make this color transparent within the image. Example: `"255,255,255"`.', + }) + + trimWhitespace = Option.String('--trim-whitespace', { + description: + 'This determines if additional whitespace around the image should first be trimmed away. If you set this to `true` this parameter removes any edges that are exactly the same color as the corner pixels.', + }) + + clip = Option.String('--clip', { + description: + 'Apply the clipping path to other operations in the resize job, if one is present. If set to `true`, it will automatically take the first clipping path. If set to a String it finds a clipping path by that name.', + }) + + negate = Option.String('--negate', { + description: + 'Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping.', + }) + + density = Option.String('--density', { + description: + 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image is unsharp, please try increasing density.', + }) + + monochrome = Option.String('--monochrome', { + description: + 'Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel.', + }) + + shave = Option.String('--shave', { + description: + 'Shave pixels from the image edges. The value should be in the format `width` or `width`x`height` to specify the number of pixels to remove from each side.', + }) + inputs = Option.Array('--input,-i', { description: 'Provide an input file or a directory', }) @@ -530,7 +809,7 @@ export class ImageResizeCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the resized image to this path or directory', + description: 'Write the result to this path or directory', required: true, }) @@ -557,16 +836,86 @@ export class ImageResizeCommand extends AuthenticatedCommand { { name: 'width', kind: 'number' }, { name: 'height', kind: 'number' }, { name: 'resize_strategy', kind: 'string' }, + { name: 'zoom', kind: 'boolean' }, + { name: 'gravity', kind: 'string' }, { name: 'strip', kind: 'boolean' }, + { name: 'alpha', kind: 'string' }, + { name: 'preclip_alpha', kind: 'string' }, + { name: 'flatten', kind: 'boolean' }, + { name: 'correct_gamma', kind: 'boolean' }, + { name: 'quality', kind: 'number' }, + { name: 'adaptive_filtering', kind: 'boolean' }, { name: 'background', kind: 'string' }, + { name: 'frame', kind: 'number' }, + { name: 'colorspace', kind: 'string' }, + { name: 'type', kind: 'string' }, + { name: 'sepia', kind: 'number' }, + { name: 'rotation', kind: 'string' }, + { name: 'compress', kind: 'string' }, + { name: 'blur', kind: 'string' }, + { name: 'brightness', kind: 'number' }, + { name: 'saturation', kind: 'number' }, + { name: 'hue', kind: 'number' }, + { name: 'contrast', kind: 'number' }, + { name: 'watermark_url', kind: 'string' }, + { name: 'watermark_x_offset', kind: 'number' }, + { name: 'watermark_y_offset', kind: 'number' }, + { name: 'watermark_size', kind: 'string' }, + { name: 'watermark_resize_strategy', kind: 'string' }, + { name: 'watermark_opacity', kind: 'number' }, + { name: 'watermark_repeat_x', kind: 'boolean' }, + { name: 'watermark_repeat_y', kind: 'boolean' }, + { name: 'progressive', kind: 'boolean' }, + { name: 'transparent', kind: 'string' }, + { name: 'trim_whitespace', kind: 'boolean' }, + { name: 'clip', kind: 'string' }, + { name: 'negate', kind: 'boolean' }, + { name: 'density', kind: 'string' }, + { name: 'monochrome', kind: 'boolean' }, + { name: 'shave', kind: 'string' }, ], rawValues: { format: this.format, width: this.width, height: this.height, resize_strategy: this.resizeStrategy, + zoom: this.zoom, + gravity: this.gravity, strip: this.strip, + alpha: this.alpha, + preclip_alpha: this.preclipAlpha, + flatten: this.flatten, + correct_gamma: this.correctGamma, + quality: this.quality, + adaptive_filtering: this.adaptiveFiltering, background: this.background, + frame: this.frame, + colorspace: this.colorspace, + type: this.type, + sepia: this.sepia, + rotation: this.rotation, + compress: this.compress, + blur: this.blur, + brightness: this.brightness, + saturation: this.saturation, + hue: this.hue, + contrast: this.contrast, + watermark_url: this.watermarkUrl, + watermark_x_offset: this.watermarkXOffset, + watermark_y_offset: this.watermarkYOffset, + watermark_size: this.watermarkSize, + watermark_resize_strategy: this.watermarkResizeStrategy, + watermark_opacity: this.watermarkOpacity, + watermark_repeat_x: this.watermarkRepeatX, + watermark_repeat_y: this.watermarkRepeatY, + progressive: this.progressive, + transparent: this.transparent, + trim_whitespace: this.trimWhitespace, + clip: this.clip, + negate: this.negate, + density: this.density, + monochrome: this.monochrome, + shave: this.shave, }, }) @@ -576,6 +925,7 @@ export class ImageResizeCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -593,18 +943,10 @@ export class DocumentConvertCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Convert a document into another format', - details: - 'Runs `/document/convert` on each input file and downloads the converted result to `--out`.', + description: 'Convert documents into different formats', + details: 'Runs `/document/convert` on each input file and writes the result to `--out`.', examples: [ - [ - 'Convert a document to PDF', - 'transloadit document convert --input proposal.docx --format pdf --out proposal.pdf', - ], - [ - 'Convert markdown using GitHub-flavored markdown', - 'transloadit document convert --input notes.md --format html --markdown-format gfm --out notes.html', - ], + ['Run the command', 'transloadit document convert --input input.pdf --out output.pdf'], ], }) @@ -623,6 +965,36 @@ export class DocumentConvertCommand extends AuthenticatedCommand { 'This parameter overhauls your Markdown files styling based on several canned presets.', }) + pdfMargin = Option.String('--pdf-margin', { + description: + 'PDF Paper margins, separated by `,` and with units.\n\nWe support the following unit values: `px`, `in`, `cm`, `mm`.\n\nCurrently this parameter is only supported when converting from `html`.', + }) + + pdfPrintBackground = Option.String('--pdf-print-background', { + description: + 'Print PDF background graphics.\n\nCurrently this parameter is only supported when converting from `html`.', + }) + + pdfFormat = Option.String('--pdf-format', { + description: + 'PDF paper format.\n\nCurrently this parameter is only supported when converting from `html`.', + }) + + pdfDisplayHeaderFooter = Option.String('--pdf-display-header-footer', { + description: + 'Display PDF header and footer.\n\nCurrently this parameter is only supported when converting from `html`.', + }) + + pdfHeaderTemplate = Option.String('--pdf-header-template', { + description: + 'HTML template for the PDF print header.\n\nShould be valid HTML markup with following classes used to inject printing values into them:\n- `date` formatted print date\n- `title` document title\n- `url` document location\n- `pageNumber` current page number\n- `totalPages` total pages in the document\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number at the top of a page you\'d use the following HTML for the header template:\n\n```html\n
\n```', + }) + + pdfFooterTemplate = Option.String('--pdf-footer-template', { + description: + 'HTML template for the PDF print footer.\n\nShould use the same format as the `pdf_header_template`.\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number in the footer you\'d use the following HTML for the footer template:\n\n```html\n
\n```', + }) + inputs = Option.Array('--input,-i', { description: 'Provide an input file or a directory', }) @@ -653,7 +1025,7 @@ export class DocumentConvertCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the converted document to this path or directory', + description: 'Write the result to this path or directory', required: true, }) @@ -679,11 +1051,23 @@ export class DocumentConvertCommand extends AuthenticatedCommand { { name: 'format', kind: 'string' }, { name: 'markdown_format', kind: 'string' }, { name: 'markdown_theme', kind: 'string' }, + { name: 'pdf_margin', kind: 'string' }, + { name: 'pdf_print_background', kind: 'boolean' }, + { name: 'pdf_format', kind: 'string' }, + { name: 'pdf_display_header_footer', kind: 'boolean' }, + { name: 'pdf_header_template', kind: 'string' }, + { name: 'pdf_footer_template', kind: 'string' }, ], rawValues: { format: this.format, markdown_format: this.markdownFormat, markdown_theme: this.markdownTheme, + pdf_margin: this.pdfMargin, + pdf_print_background: this.pdfPrintBackground, + pdf_format: this.pdfFormat, + pdf_display_header_footer: this.pdfDisplayHeaderFooter, + pdf_header_template: this.pdfHeaderTemplate, + pdf_footer_template: this.pdfFooterTemplate, }, }) @@ -693,6 +1077,7 @@ export class DocumentConvertCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -711,17 +1096,9 @@ export class DocumentOptimizeCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', description: 'Reduce PDF file size', - details: - 'Runs `/document/optimize` on each input PDF and downloads the optimized result to `--out`.', + details: 'Runs `/document/optimize` on each input file and writes the result to `--out`.', examples: [ - [ - 'Optimize a PDF with the ebook preset', - 'transloadit document optimize --input report.pdf --preset ebook --out report-optimized.pdf', - ], - [ - 'Override image DPI', - 'transloadit document optimize --input report.pdf --image-dpi 150 --out report-optimized.pdf', - ], + ['Run the command', 'transloadit document optimize --input input.pdf --out output.pdf'], ], }) @@ -790,7 +1167,7 @@ export class DocumentOptimizeCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the optimized PDF to this path or directory', + description: 'Write the result to this path or directory', required: true, }) @@ -838,6 +1215,7 @@ export class DocumentOptimizeCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -855,14 +1233,10 @@ export class DocumentAutoRotateCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Correct document page orientation', - details: - 'Runs `/document/autorotate` on each input file and downloads the corrected document to `--out`.', + description: 'Auto-rotate documents to the correct orientation', + details: 'Runs `/document/autorotate` on each input file and writes the result to `--out`.', examples: [ - [ - 'Auto-rotate a scanned PDF', - 'transloadit document auto-rotate --input scans.pdf --out scans-corrected.pdf', - ], + ['Run the command', 'transloadit document auto-rotate --input input.pdf --out output.pdf'], ], }) @@ -896,7 +1270,7 @@ export class DocumentAutoRotateCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the auto-rotated document to this path or directory', + description: 'Write the result to this path or directory', required: true, }) @@ -928,6 +1302,7 @@ export class DocumentAutoRotateCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -945,19 +1320,9 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Render thumbnails from a document', - details: - 'Runs `/document/thumbs` on each input document and writes the extracted pages or animated GIF to `--out`.', - examples: [ - [ - 'Extract PNG thumbnails from every page', - 'transloadit document thumbs --input brochure.pdf --width 240 --out thumbs/', - ], - [ - 'Generate an animated GIF preview', - 'transloadit document thumbs --input brochure.pdf --format gif --delay 50 --out brochure.gif', - ], - ], + description: 'Extract thumbnail images from documents', + details: 'Runs `/document/thumbs` on each input file and writes the results to `--out`.', + examples: [['Run the command', 'transloadit document thumbs --input input.pdf --out output/']], }) page = Option.String('--page', { @@ -994,6 +1359,26 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (only used for the pad resize strategy).\n\nBy default, the background of transparent images is changed to white. For details about how to preserve transparency across all image types, see [this demo](/demos/image-manipulation/properly-preserve-transparency-across-all-image-types/).', }) + alpha = Option.String('--alpha', { + description: + 'Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency.\n\nFor a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha).', + }) + + density = Option.String('--density', { + description: + 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image has a low resolution, please try using the density parameter to resolve that.', + }) + + antialiasing = Option.String('--antialiasing', { + description: + 'Controls whether or not antialiasing is used to remove jagged edges from text or images in a document.', + }) + + colorspace = Option.String('--colorspace', { + description: + 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace).\n\nPlease note that if you were using `"RGB"`, we recommend using `"sRGB"`. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might then have to use this parameter.', + }) + trimWhitespace = Option.String('--trim-whitespace', { description: "This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. If you set this to `true` only the real PDF page contents will be shown in the image.\n\nIf you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`.", @@ -1004,6 +1389,11 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { "Some PDF documents lie about their dimensions. For instance they'll say they are landscape, but when opened in decent Desktop readers, it's really in portrait mode. This can happen if the document has a cropbox defined. When this option is enabled (by default), the cropbox is leading in determining the dimensions of the resulting thumbnails.", }) + turbo = Option.String('--turbo', { + description: + "If you set this to `false`, the robot will not emit files as they become available. This is useful if you are only interested in the final result and not in the intermediate steps.\n\nAlso, extracted pages will be resized a lot faster as they are sent off to other machines for the resizing. This is especially useful for large documents with many pages to get up to 20 times faster processing.\n\nTurbo Mode increases pricing, though, in that the input document's file size is added for every extracted page. There are no performance benefits nor increased charges for single-page documents.", + }) + inputs = Option.Array('--input,-i', { description: 'Provide an input file or a directory', }) @@ -1034,7 +1424,7 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the extracted document thumbnails to this path or directory', + description: 'Write the results to this directory', required: true, }) @@ -1064,8 +1454,13 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { { name: 'height', kind: 'number' }, { name: 'resize_strategy', kind: 'string' }, { name: 'background', kind: 'string' }, + { name: 'alpha', kind: 'string' }, + { name: 'density', kind: 'string' }, + { name: 'antialiasing', kind: 'boolean' }, + { name: 'colorspace', kind: 'string' }, { name: 'trim_whitespace', kind: 'boolean' }, { name: 'pdf_use_cropbox', kind: 'boolean' }, + { name: 'turbo', kind: 'boolean' }, ], rawValues: { page: this.page, @@ -1075,8 +1470,13 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { height: this.height, resize_strategy: this.resizeStrategy, background: this.background, + alpha: this.alpha, + density: this.density, + antialiasing: this.antialiasing, + colorspace: this.colorspace, trim_whitespace: this.trimWhitespace, pdf_use_cropbox: this.pdfUseCropbox, + turbo: this.turbo, }, }) @@ -1104,18 +1504,10 @@ export class AudioWaveformCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Generate a waveform image from audio', - details: - 'Runs `/audio/waveform` on each input audio file and downloads the waveform to `--out`.', + description: 'Generate waveform images from audio', + details: 'Runs `/audio/waveform` on each input file and writes the result to `--out`.', examples: [ - [ - 'Generate a waveform PNG', - 'transloadit audio waveform --input podcast.mp3 --width 1200 --height 300 --out waveform.png', - ], - [ - 'Generate waveform JSON', - 'transloadit audio waveform --input podcast.mp3 --format json --out waveform.json', - ], + ['Run the command', 'transloadit audio waveform --input input.mp3 --out output.png'], ], }) @@ -1132,9 +1524,9 @@ export class AudioWaveformCommand extends AuthenticatedCommand { description: 'The height of the resulting image if the format `"image"` was selected.', }) - style = Option.String('--style', { + antialiasing = Option.String('--antialiasing', { description: - 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', + 'Either a value of `0` or `1`, or `true`/`false`, corresponding to if you want to enable antialiasing to achieve smoother edges in the waveform graph or not.', }) backgroundColor = Option.String('--background-color', { @@ -1152,6 +1544,88 @@ export class AudioWaveformCommand extends AuthenticatedCommand { 'The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', }) + style = Option.String('--style', { + description: + 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', + }) + + splitChannels = Option.String('--split-channels', { + description: + 'Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel.', + }) + + zoom = Option.String('--zoom', { + description: + 'Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`.', + }) + + pixelsPerSecond = Option.String('--pixels-per-second', { + description: + 'Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`.', + }) + + bits = Option.String('--bits', { + description: 'Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16.', + }) + + start = Option.String('--start', { + description: 'Available when style is `"v1"`. Start time in seconds.', + }) + + end = Option.String('--end', { + description: 'Available when style is `"v1"`. End time in seconds (0 means end of audio).', + }) + + colors = Option.String('--colors', { + description: + 'Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity".', + }) + + borderColor = Option.String('--border-color', { + description: 'Available when style is `"v1"`. Border color in "rrggbbaa" format.', + }) + + waveformStyle = Option.String('--waveform-style', { + description: 'Available when style is `"v1"`. Waveform style. Can be "normal" or "bars".', + }) + + barWidth = Option.String('--bar-width', { + description: + 'Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars".', + }) + + barGap = Option.String('--bar-gap', { + description: + 'Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars".', + }) + + barStyle = Option.String('--bar-style', { + description: 'Available when style is `"v1"`. Bar style when waveform_style is "bars".', + }) + + axisLabelColor = Option.String('--axis-label-color', { + description: 'Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format.', + }) + + noAxisLabels = Option.String('--no-axis-labels', { + description: + 'Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels.', + }) + + withAxisLabels = Option.String('--with-axis-labels', { + description: + 'Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels.', + }) + + amplitudeScale = Option.String('--amplitude-scale', { + description: 'Available when style is `"v1"`. Amplitude scale factor.', + }) + + compression = Option.String('--compression', { + description: + 'Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image".', + }) + inputs = Option.Array('--input,-i', { description: 'Provide an input file or a directory', }) @@ -1182,7 +1656,7 @@ export class AudioWaveformCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the waveform image or JSON data to this path or directory', + description: 'Write the result to this path or directory', required: true, }) @@ -1208,19 +1682,55 @@ export class AudioWaveformCommand extends AuthenticatedCommand { { name: 'format', kind: 'string' }, { name: 'width', kind: 'number' }, { name: 'height', kind: 'number' }, - { name: 'style', kind: 'string' }, + { name: 'antialiasing', kind: 'string' }, { name: 'background_color', kind: 'string' }, { name: 'center_color', kind: 'string' }, { name: 'outer_color', kind: 'string' }, + { name: 'style', kind: 'string' }, + { name: 'split_channels', kind: 'boolean' }, + { name: 'zoom', kind: 'number' }, + { name: 'pixels_per_second', kind: 'number' }, + { name: 'bits', kind: 'number' }, + { name: 'start', kind: 'number' }, + { name: 'end', kind: 'number' }, + { name: 'colors', kind: 'string' }, + { name: 'border_color', kind: 'string' }, + { name: 'waveform_style', kind: 'string' }, + { name: 'bar_width', kind: 'number' }, + { name: 'bar_gap', kind: 'number' }, + { name: 'bar_style', kind: 'string' }, + { name: 'axis_label_color', kind: 'string' }, + { name: 'no_axis_labels', kind: 'boolean' }, + { name: 'with_axis_labels', kind: 'boolean' }, + { name: 'amplitude_scale', kind: 'number' }, + { name: 'compression', kind: 'number' }, ], rawValues: { format: this.format, width: this.width, height: this.height, - style: this.style, + antialiasing: this.antialiasing, background_color: this.backgroundColor, center_color: this.centerColor, outer_color: this.outerColor, + style: this.style, + split_channels: this.splitChannels, + zoom: this.zoom, + pixels_per_second: this.pixelsPerSecond, + bits: this.bits, + start: this.start, + end: this.end, + colors: this.colors, + border_color: this.borderColor, + waveform_style: this.waveformStyle, + bar_width: this.barWidth, + bar_gap: this.barGap, + bar_style: this.barStyle, + axis_label_color: this.axisLabelColor, + no_axis_labels: this.noAxisLabels, + with_axis_labels: this.withAxisLabels, + amplitude_scale: this.amplitudeScale, + compression: this.compression, }, }) @@ -1230,6 +1740,7 @@ export class AudioWaveformCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, watch: this.watch, del: this.deleteAfterProcessing, @@ -1247,17 +1758,10 @@ export class TextSpeakCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Turn a text prompt into spoken audio', - details: 'Runs `/text/speak` with a prompt and downloads the synthesized audio to `--out`.', + description: 'Speak text', + details: 'Runs `/text/speak` and writes the result to `--out`.', examples: [ - [ - 'Speak a sentence in American English', - 'transloadit text speak --prompt "Hello world" --provider aws --target-language en-US --out hello.mp3', - ], - [ - 'Use a different voice', - 'transloadit text speak --prompt "Bonjour tout le monde" --provider aws --target-language fr-FR --voice female-2 --out bonjour.mp3', - ], + ['Run the command', 'transloadit text speak --prompt "Hello world" --out output.mp3'], ], }) @@ -1289,7 +1793,7 @@ export class TextSpeakCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the synthesized audio to this path', + description: 'Write the result to this path', required: true, }) @@ -1333,18 +1837,9 @@ export class VideoThumbsCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Extract thumbnails from a video', - details: 'Runs `/video/thumbs` on each input video and writes the extracted images to `--out`.', - examples: [ - [ - 'Extract eight thumbnails', - 'transloadit video thumbs --input demo.mp4 --count 8 --out thumbs/', - ], - [ - 'Resize thumbnails to PNG', - 'transloadit video thumbs --input demo.mp4 --count 5 --format png --width 640 --out thumbs/', - ], - ], + description: 'Extract thumbnails from videos', + details: 'Runs `/video/thumbs` on each input file and writes the results to `--out`.', + examples: [['Run the command', 'transloadit video thumbs --input input.mp4 --out output/']], }) count = Option.String('--count', { @@ -1381,6 +1876,11 @@ export class VideoThumbsCommand extends AuthenticatedCommand { 'Forces the video to be rotated by the specified degree integer. Currently, only multiples of 90 are supported. We automatically correct the orientation of many videos when the orientation is provided by the camera. This option is only useful for videos requiring rotation because it was not detected by the camera.', }) + inputCodec = Option.String('--input-codec', { + description: + 'Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders.', + }) + inputs = Option.Array('--input,-i', { description: 'Provide an input file or a directory', }) @@ -1411,7 +1911,7 @@ export class VideoThumbsCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the extracted video thumbnails to this path or directory', + description: 'Write the results to this directory', required: true, }) @@ -1441,6 +1941,7 @@ export class VideoThumbsCommand extends AuthenticatedCommand { { name: 'resize_strategy', kind: 'string' }, { name: 'background', kind: 'string' }, { name: 'rotate', kind: 'number' }, + { name: 'input_codec', kind: 'string' }, ], rawValues: { count: this.count, @@ -1450,6 +1951,7 @@ export class VideoThumbsCommand extends AuthenticatedCommand { resize_strategy: this.resizeStrategy, background: this.background, rotate: this.rotate, + input_codec: this.inputCodec, }, }) @@ -1477,16 +1979,10 @@ export class VideoEncodeHlsCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Encode a video into an HLS package', + description: 'Run builtin/encode-hls-video@latest', details: - 'Runs the `builtin/encode-hls-video@latest` builtin template and downloads the HLS outputs into `--out`.', - examples: [ - ['Encode a single video', 'transloadit video encode-hls --input input.mp4 --out dist/hls'], - [ - 'Process a directory recursively', - 'transloadit video encode-hls --input videos/ --out dist/hls --recursive', - ], - ], + 'Runs the `builtin/encode-hls-video@latest` template and writes the outputs to `--out`.', + examples: [['Run the command', 'transloadit video encode-hls --input input.mp4 --out output/']], }) inputs = Option.Array('--input,-i', { @@ -1519,7 +2015,7 @@ export class VideoEncodeHlsCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the HLS outputs into this directory', + description: 'Write the results to this directory', required: true, }) @@ -1556,18 +2052,10 @@ export class FileCompressCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Create an archive from one or more files', - details: - 'Runs `/file/compress` and writes the resulting archive to `--out`. Multiple inputs are bundled into one archive by default.', + description: 'Compress files', + details: 'Runs `/file/compress` for the provided inputs and writes the result to `--out`.', examples: [ - [ - 'Create a ZIP archive', - 'transloadit file compress --input assets/ --format zip --out assets.zip', - ], - [ - 'Create a gzipped tarball', - 'transloadit file compress --input assets/ --format tar --gzip true --out assets.tar.gz', - ], + ['Run the command', 'transloadit file compress --input input.file --out archive.zip'], ], }) @@ -1617,7 +2105,7 @@ export class FileCompressCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the generated archive to this path', + description: 'Write the result to this path or directory', required: true, }) @@ -1661,6 +2149,7 @@ export class FileCompressCommand extends AuthenticatedCommand { }, inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', recursive: this.recursive, del: this.deleteAfterProcessing, reprocessStale: this.reprocessStale, @@ -1676,15 +2165,9 @@ export class FileDecompressCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Decompress an archive', - details: - 'Runs `/file/decompress` on each input archive and writes the extracted files to `--out`.', - examples: [ - [ - 'Decompress a ZIP archive', - 'transloadit file decompress --input assets.zip --out extracted/', - ], - ], + description: 'Decompress archives', + details: 'Runs `/file/decompress` on each input file and writes the results to `--out`.', + examples: [['Run the command', 'transloadit file decompress --input input.file --out output/']], }) inputs = Option.Array('--input,-i', { @@ -1717,7 +2200,7 @@ export class FileDecompressCommand extends AuthenticatedCommand { }) outputPath = Option.String('--out,-o', { - description: 'Write the extracted files to this directory', + description: 'Write the results to this directory', required: true, }) diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index 20ae8dc9..bd315f14 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -1,657 +1,247 @@ import type { z } from 'zod' -import { robotAudioWaveformInstructionsSchema } from '../alphalib/types/robots/audio-waveform.ts' -import { robotDocumentAutorotateInstructionsSchema } from '../alphalib/types/robots/document-autorotate.ts' -import { robotDocumentConvertInstructionsSchema } from '../alphalib/types/robots/document-convert.ts' -import { robotDocumentOptimizeInstructionsSchema } from '../alphalib/types/robots/document-optimize.ts' -import { robotDocumentThumbsInstructionsSchema } from '../alphalib/types/robots/document-thumbs.ts' -import { robotFileCompressInstructionsSchema } from '../alphalib/types/robots/file-compress.ts' -import { robotFileDecompressInstructionsSchema } from '../alphalib/types/robots/file-decompress.ts' +import type { RobotMetaInput } from '../alphalib/types/robots/_instructions-primitives.ts' +import { + robotAudioWaveformInstructionsSchema, + meta as robotAudioWaveformMeta, +} from '../alphalib/types/robots/audio-waveform.ts' +import { + robotDocumentAutorotateInstructionsSchema, + meta as robotDocumentAutorotateMeta, +} from '../alphalib/types/robots/document-autorotate.ts' +import { + robotDocumentConvertInstructionsSchema, + meta as robotDocumentConvertMeta, +} from '../alphalib/types/robots/document-convert.ts' +import { + robotDocumentOptimizeInstructionsSchema, + meta as robotDocumentOptimizeMeta, +} from '../alphalib/types/robots/document-optimize.ts' +import { + robotDocumentThumbsInstructionsSchema, + meta as robotDocumentThumbsMeta, +} from '../alphalib/types/robots/document-thumbs.ts' +import { + robotFileCompressInstructionsSchema, + meta as robotFileCompressMeta, +} from '../alphalib/types/robots/file-compress.ts' +import { + robotFileDecompressInstructionsSchema, + meta as robotFileDecompressMeta, +} from '../alphalib/types/robots/file-decompress.ts' import { robotFilePreviewInstructionsSchema } from '../alphalib/types/robots/file-preview.ts' -import { robotImageBgremoveInstructionsSchema } from '../alphalib/types/robots/image-bgremove.ts' -import { robotImageGenerateInstructionsSchema } from '../alphalib/types/robots/image-generate.ts' -import { robotImageOptimizeInstructionsSchema } from '../alphalib/types/robots/image-optimize.ts' -import { robotImageResizeInstructionsSchema } from '../alphalib/types/robots/image-resize.ts' -import { robotTextSpeakInstructionsSchema } from '../alphalib/types/robots/text-speak.ts' -import { robotVideoThumbsInstructionsSchema } from '../alphalib/types/robots/video-thumbs.ts' +import { + robotImageBgremoveInstructionsSchema, + meta as robotImageBgremoveMeta, +} from '../alphalib/types/robots/image-bgremove.ts' +import { + robotImageGenerateInstructionsSchema, + meta as robotImageGenerateMeta, +} from '../alphalib/types/robots/image-generate.ts' +import { + robotImageOptimizeInstructionsSchema, + meta as robotImageOptimizeMeta, +} from '../alphalib/types/robots/image-optimize.ts' +import { + robotImageResizeInstructionsSchema, + meta as robotImageResizeMeta, +} from '../alphalib/types/robots/image-resize.ts' +import { + robotTextSpeakInstructionsSchema, + meta as robotTextSpeakMeta, +} from '../alphalib/types/robots/text-speak.ts' +import { + robotVideoThumbsInstructionsSchema, + meta as robotVideoThumbsMeta, +} from '../alphalib/types/robots/video-thumbs.ts' -export interface IntentSchemaOptionSpec { - importName: string - importPath: string - keys: string[] - requiredKeys?: string[] - schema: z.AnyZodObject -} +export type IntentInputMode = 'local-files' | 'none' | 'remote-url' +export type IntentOutputMode = 'directory' | 'file' -export interface IntentInputNoneSpec { - kind: 'none' -} - -export interface IntentInputRemoteUrlSpec { - description: string - kind: 'remote-url' +export interface RobotIntentDefinition { + meta: RobotMetaInput + robot: string + schema: z.AnyZodObject + schemaImportName: string + schemaImportPath: string } -export interface IntentInputLocalFilesSpec { - allowConcurrency?: boolean - allowSingleAssembly?: boolean - allowWatch?: boolean +export interface RobotIntentCatalogEntry { + kind: 'robot' defaultSingleAssembly?: boolean - deleteAfterProcessing?: boolean - description: string - kind: 'local-files' - recursive?: boolean - reprocessStale?: boolean + inputMode?: Exclude + outputMode?: IntentOutputMode + robot: keyof typeof robotIntentDefinitions } -export type IntentInputSpec = - | IntentInputLocalFilesSpec - | IntentInputNoneSpec - | IntentInputRemoteUrlSpec - -export interface IntentTemplateExecutionSpec { +export interface TemplateIntentCatalogEntry { kind: 'template' + outputMode?: IntentOutputMode + paths: string[] templateId: string } -export interface IntentSingleStepExecutionSpec { - fixedValues: Record - kind: 'single-step' - resultStepName: string +export interface RecipeIntentCatalogEntry { + kind: 'recipe' + recipe: keyof typeof intentRecipeDefinitions } -export interface IntentRemotePreviewExecutionSpec { - fixedValues: Record - importStepName: string - kind: 'remote-preview' - previewStepName: string -} +export type IntentCatalogEntry = + | RecipeIntentCatalogEntry + | RobotIntentCatalogEntry + | TemplateIntentCatalogEntry -export type IntentExecutionSpec = - | IntentRemotePreviewExecutionSpec - | IntentSingleStepExecutionSpec - | IntentTemplateExecutionSpec - -export interface IntentCommandSpec { - className: string +export interface IntentRecipeDefinition { description: string - details?: string + details: string examples: Array<[string, string]> - execution: IntentExecutionSpec - input: IntentInputSpec - outputMode?: 'directory' | 'file' + inputMode: 'remote-url' outputDescription: string outputRequired: boolean - paths: string[][] - schemaOptions?: IntentSchemaOptionSpec + paths: string[] + resultStepName: string + schema: z.AnyZodObject + schemaImportName: string + schemaImportPath: string summary: string } -const localFileInput = { - kind: 'local-files', - description: 'Provide an input file or a directory', - recursive: true, - allowWatch: true, - deleteAfterProcessing: true, - reprocessStale: true, - allowSingleAssembly: true, - allowConcurrency: true, -} satisfies IntentInputLocalFilesSpec - -export const intentCommandSpecs = [ - { - className: 'ImageGenerateCommand', - summary: 'Generate images from text prompts', - description: 'Generate an image from a prompt', - details: - 'Creates a one-off assembly around `/image/generate` and downloads the result to `--out`.', - paths: [['image', 'generate']], - input: { kind: 'none' }, - outputDescription: 'Write the generated image to this path', - outputRequired: true, - examples: [ - [ - 'Generate a PNG image', - 'transloadit image generate --prompt "A red bicycle in a studio" --out bicycle.png', - ], - [ - 'Pick a model and aspect ratio', - 'transloadit image generate --prompt "An astronaut riding a horse" --model flux-schnell --aspect-ratio 2:3 --out horse.png', - ], - ], - schemaOptions: { - importName: 'robotImageGenerateInstructionsSchema', - importPath: '../../alphalib/types/robots/image-generate.ts', - schema: robotImageGenerateInstructionsSchema, - keys: ['prompt', 'model', 'format', 'seed', 'aspect_ratio', 'height', 'width', 'style'], - }, - execution: { - kind: 'single-step', - resultStepName: 'generated_image', - fixedValues: { - robot: '/image/generate', - result: true, - }, - }, +export const robotIntentDefinitions = { + '/audio/waveform': { + robot: '/audio/waveform', + meta: robotAudioWaveformMeta, + schema: robotAudioWaveformInstructionsSchema, + schemaImportName: 'robotAudioWaveformInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/audio-waveform.ts', }, - { - className: 'PreviewGenerateCommand', - summary: 'Generate preview thumbnails for remote files', - description: 'Generate a preview image for a remote file URL', - details: - 'Imports a remote file with `/http/import`, then runs `/file/preview` and downloads the preview to `--out`.', - paths: [['preview', 'generate']], - input: { - kind: 'remote-url', - description: 'Remote URL to preview', - }, - outputDescription: 'Write the generated preview image to this path', - outputRequired: true, - examples: [ - [ - 'Preview a remote PDF', - 'transloadit preview generate --input https://example.com/file.pdf --width 300 --height 200 --out preview.png', - ], - [ - 'Pick a format and resize strategy', - 'transloadit preview generate --input https://example.com/file.mp4 --width 640 --height 360 --format jpg --resize-strategy fillcrop --out preview.jpg', - ], - ], - schemaOptions: { - importName: 'robotFilePreviewInstructionsSchema', - importPath: '../../alphalib/types/robots/file-preview.ts', - schema: robotFilePreviewInstructionsSchema, - keys: ['format', 'width', 'height', 'resize_strategy'], - }, - execution: { - kind: 'remote-preview', - importStepName: 'imported', - previewStepName: 'preview', - fixedValues: { - robot: '/file/preview', - result: true, - }, - }, + '/document/autorotate': { + robot: '/document/autorotate', + meta: robotDocumentAutorotateMeta, + schema: robotDocumentAutorotateInstructionsSchema, + schemaImportName: 'robotDocumentAutorotateInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-autorotate.ts', }, - { - className: 'ImageRemoveBackgroundCommand', - summary: 'Remove image backgrounds', - description: 'Remove the background from an image', - details: 'Runs `/image/bgremove` on each input image and downloads the result to `--out`.', - paths: [['image', 'remove-background']], - input: localFileInput, - outputDescription: 'Write the background-removed image to this path or directory', - outputRequired: true, - examples: [ - [ - 'Remove the background from one image', - 'transloadit image remove-background --input portrait.png --out portrait-cutout.png', - ], - [ - 'Choose the output format', - 'transloadit image remove-background --input portrait.png --format webp --out portrait-cutout.webp', - ], - ], - schemaOptions: { - importName: 'robotImageBgremoveInstructionsSchema', - importPath: '../../alphalib/types/robots/image-bgremove.ts', - schema: robotImageBgremoveInstructionsSchema, - keys: ['select', 'format', 'provider', 'model'], - }, - execution: { - kind: 'single-step', - resultStepName: 'removed_background', - fixedValues: { - robot: '/image/bgremove', - result: true, - use: ':original', - }, - }, + '/document/convert': { + robot: '/document/convert', + meta: robotDocumentConvertMeta, + schema: robotDocumentConvertInstructionsSchema, + schemaImportName: 'robotDocumentConvertInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-convert.ts', }, - { - className: 'ImageOptimizeCommand', - summary: 'Optimize images', - description: 'Optimize image file size', - details: 'Runs `/image/optimize` on each input image and downloads the result to `--out`.', - paths: [['image', 'optimize']], - input: localFileInput, - outputDescription: 'Write the optimized image to this path or directory', - outputRequired: true, - examples: [ - [ - 'Optimize a single image', - 'transloadit image optimize --input hero.jpg --out hero-optimized.jpg', - ], - [ - 'Prioritize compression ratio', - 'transloadit image optimize --input hero.jpg --priority compression-ratio --out hero-optimized.jpg', - ], - ], - schemaOptions: { - importName: 'robotImageOptimizeInstructionsSchema', - importPath: '../../alphalib/types/robots/image-optimize.ts', - schema: robotImageOptimizeInstructionsSchema, - keys: ['priority', 'progressive', 'preserve_meta_data', 'fix_breaking_images'], - }, - execution: { - kind: 'single-step', - resultStepName: 'optimized', - fixedValues: { - robot: '/image/optimize', - result: true, - use: ':original', - }, - }, + '/document/optimize': { + robot: '/document/optimize', + meta: robotDocumentOptimizeMeta, + schema: robotDocumentOptimizeInstructionsSchema, + schemaImportName: 'robotDocumentOptimizeInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-optimize.ts', }, - { - className: 'ImageResizeCommand', - summary: 'Resize images', - description: 'Resize an image', - details: 'Runs `/image/resize` on each input image and downloads the result to `--out`.', - paths: [['image', 'resize']], - input: localFileInput, - outputDescription: 'Write the resized image to this path or directory', - outputRequired: true, - examples: [ - [ - 'Resize an image to 800×600', - 'transloadit image resize --input photo.jpg --width 800 --height 600 --out photo-resized.jpg', - ], - [ - 'Pad with a transparent background', - 'transloadit image resize --input logo.png --width 512 --height 512 --resize-strategy pad --background none --out logo-square.png', - ], - ], - schemaOptions: { - importName: 'robotImageResizeInstructionsSchema', - importPath: '../../alphalib/types/robots/image-resize.ts', - schema: robotImageResizeInstructionsSchema, - keys: ['format', 'width', 'height', 'resize_strategy', 'strip', 'background'], - }, - execution: { - kind: 'single-step', - resultStepName: 'resized', - fixedValues: { - robot: '/image/resize', - result: true, - use: ':original', - }, - }, + '/document/thumbs': { + robot: '/document/thumbs', + meta: robotDocumentThumbsMeta, + schema: robotDocumentThumbsInstructionsSchema, + schemaImportName: 'robotDocumentThumbsInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-thumbs.ts', }, - { - className: 'DocumentConvertCommand', - summary: 'Convert documents', - description: 'Convert a document into another format', - details: - 'Runs `/document/convert` on each input file and downloads the converted result to `--out`.', - paths: [['document', 'convert']], - input: localFileInput, - outputDescription: 'Write the converted document to this path or directory', - outputRequired: true, - examples: [ - [ - 'Convert a document to PDF', - 'transloadit document convert --input proposal.docx --format pdf --out proposal.pdf', - ], - [ - 'Convert markdown using GitHub-flavored markdown', - 'transloadit document convert --input notes.md --format html --markdown-format gfm --out notes.html', - ], - ], - schemaOptions: { - importName: 'robotDocumentConvertInstructionsSchema', - importPath: '../../alphalib/types/robots/document-convert.ts', - schema: robotDocumentConvertInstructionsSchema, - keys: ['format', 'markdown_format', 'markdown_theme'], - }, - execution: { - kind: 'single-step', - resultStepName: 'converted', - fixedValues: { - robot: '/document/convert', - result: true, - use: ':original', - }, - }, + '/file/compress': { + robot: '/file/compress', + meta: robotFileCompressMeta, + schema: robotFileCompressInstructionsSchema, + schemaImportName: 'robotFileCompressInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/file-compress.ts', }, - { - className: 'DocumentOptimizeCommand', - summary: 'Optimize PDF documents', - description: 'Reduce PDF file size', - details: - 'Runs `/document/optimize` on each input PDF and downloads the optimized result to `--out`.', - paths: [['document', 'optimize']], - input: localFileInput, - outputDescription: 'Write the optimized PDF to this path or directory', - outputRequired: true, - examples: [ - [ - 'Optimize a PDF with the ebook preset', - 'transloadit document optimize --input report.pdf --preset ebook --out report-optimized.pdf', - ], - [ - 'Override image DPI', - 'transloadit document optimize --input report.pdf --image-dpi 150 --out report-optimized.pdf', - ], - ], - schemaOptions: { - importName: 'robotDocumentOptimizeInstructionsSchema', - importPath: '../../alphalib/types/robots/document-optimize.ts', - schema: robotDocumentOptimizeInstructionsSchema, - keys: [ - 'preset', - 'image_dpi', - 'compress_fonts', - 'subset_fonts', - 'remove_metadata', - 'linearize', - 'compatibility', - ], - }, - execution: { - kind: 'single-step', - resultStepName: 'optimized', - fixedValues: { - robot: '/document/optimize', - result: true, - use: ':original', - }, - }, + '/file/decompress': { + robot: '/file/decompress', + meta: robotFileDecompressMeta, + schema: robotFileDecompressInstructionsSchema, + schemaImportName: 'robotFileDecompressInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/file-decompress.ts', }, - { - className: 'DocumentAutoRotateCommand', - summary: 'Auto-rotate documents', - description: 'Correct document page orientation', - details: - 'Runs `/document/autorotate` on each input file and downloads the corrected document to `--out`.', - paths: [['document', 'auto-rotate']], - input: localFileInput, - outputDescription: 'Write the auto-rotated document to this path or directory', - outputRequired: true, - examples: [ - [ - 'Auto-rotate a scanned PDF', - 'transloadit document auto-rotate --input scans.pdf --out scans-corrected.pdf', - ], - ], - schemaOptions: { - importName: 'robotDocumentAutorotateInstructionsSchema', - importPath: '../../alphalib/types/robots/document-autorotate.ts', - schema: robotDocumentAutorotateInstructionsSchema, - keys: [], - }, - execution: { - kind: 'single-step', - resultStepName: 'autorotated', - fixedValues: { - robot: '/document/autorotate', - result: true, - use: ':original', - }, - }, + '/image/bgremove': { + robot: '/image/bgremove', + meta: robotImageBgremoveMeta, + schema: robotImageBgremoveInstructionsSchema, + schemaImportName: 'robotImageBgremoveInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/image-bgremove.ts', }, - { - className: 'DocumentThumbsCommand', - summary: 'Extract document thumbnails', - description: 'Render thumbnails from a document', - details: - 'Runs `/document/thumbs` on each input document and writes the extracted pages or animated GIF to `--out`.', - paths: [['document', 'thumbs']], - input: localFileInput, - outputMode: 'directory', - outputDescription: 'Write the extracted document thumbnails to this path or directory', - outputRequired: true, - examples: [ - [ - 'Extract PNG thumbnails from every page', - 'transloadit document thumbs --input brochure.pdf --width 240 --out thumbs/', - ], - [ - 'Generate an animated GIF preview', - 'transloadit document thumbs --input brochure.pdf --format gif --delay 50 --out brochure.gif', - ], - ], - schemaOptions: { - importName: 'robotDocumentThumbsInstructionsSchema', - importPath: '../../alphalib/types/robots/document-thumbs.ts', - schema: robotDocumentThumbsInstructionsSchema, - keys: [ - 'page', - 'format', - 'delay', - 'width', - 'height', - 'resize_strategy', - 'background', - 'trim_whitespace', - 'pdf_use_cropbox', - ], - }, - execution: { - kind: 'single-step', - resultStepName: 'thumbnailed', - fixedValues: { - robot: '/document/thumbs', - result: true, - use: ':original', - }, - }, + '/image/generate': { + robot: '/image/generate', + meta: robotImageGenerateMeta, + schema: robotImageGenerateInstructionsSchema, + schemaImportName: 'robotImageGenerateInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/image-generate.ts', }, - { - className: 'AudioWaveformCommand', - summary: 'Generate audio waveforms', - description: 'Generate a waveform image from audio', - details: - 'Runs `/audio/waveform` on each input audio file and downloads the waveform to `--out`.', - paths: [['audio', 'waveform']], - input: localFileInput, - outputDescription: 'Write the waveform image or JSON data to this path or directory', - outputRequired: true, - examples: [ - [ - 'Generate a waveform PNG', - 'transloadit audio waveform --input podcast.mp3 --width 1200 --height 300 --out waveform.png', - ], - [ - 'Generate waveform JSON', - 'transloadit audio waveform --input podcast.mp3 --format json --out waveform.json', - ], - ], - schemaOptions: { - importName: 'robotAudioWaveformInstructionsSchema', - importPath: '../../alphalib/types/robots/audio-waveform.ts', - schema: robotAudioWaveformInstructionsSchema, - keys: [ - 'format', - 'width', - 'height', - 'style', - 'background_color', - 'center_color', - 'outer_color', - ], - }, - execution: { - kind: 'single-step', - resultStepName: 'waveformed', - fixedValues: { - robot: '/audio/waveform', - result: true, - use: ':original', - }, - }, + '/image/optimize': { + robot: '/image/optimize', + meta: robotImageOptimizeMeta, + schema: robotImageOptimizeInstructionsSchema, + schemaImportName: 'robotImageOptimizeInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/image-optimize.ts', }, - { - className: 'TextSpeakCommand', - summary: 'Synthesize speech from text', - description: 'Turn a text prompt into spoken audio', - details: 'Runs `/text/speak` with a prompt and downloads the synthesized audio to `--out`.', - paths: [['text', 'speak']], - input: { kind: 'none' }, - outputDescription: 'Write the synthesized audio to this path', - outputRequired: true, - examples: [ - [ - 'Speak a sentence in American English', - 'transloadit text speak --prompt "Hello world" --provider aws --target-language en-US --out hello.mp3', - ], - [ - 'Use a different voice', - 'transloadit text speak --prompt "Bonjour tout le monde" --provider aws --target-language fr-FR --voice female-2 --out bonjour.mp3', - ], - ], - schemaOptions: { - importName: 'robotTextSpeakInstructionsSchema', - importPath: '../../alphalib/types/robots/text-speak.ts', - schema: robotTextSpeakInstructionsSchema, - keys: ['prompt', 'provider', 'target_language', 'voice', 'ssml'], - requiredKeys: ['prompt'], - }, - execution: { - kind: 'single-step', - resultStepName: 'synthesized', - fixedValues: { - robot: '/text/speak', - result: true, - }, - }, + '/image/resize': { + robot: '/image/resize', + meta: robotImageResizeMeta, + schema: robotImageResizeInstructionsSchema, + schemaImportName: 'robotImageResizeInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/image-resize.ts', }, - { - className: 'VideoThumbsCommand', - summary: 'Extract video thumbnails', - description: 'Extract thumbnails from a video', - details: 'Runs `/video/thumbs` on each input video and writes the extracted images to `--out`.', - paths: [['video', 'thumbs']], - input: localFileInput, - outputMode: 'directory', - outputDescription: 'Write the extracted video thumbnails to this path or directory', - outputRequired: true, - examples: [ - [ - 'Extract eight thumbnails', - 'transloadit video thumbs --input demo.mp4 --count 8 --out thumbs/', - ], - [ - 'Resize thumbnails to PNG', - 'transloadit video thumbs --input demo.mp4 --count 5 --format png --width 640 --out thumbs/', - ], - ], - schemaOptions: { - importName: 'robotVideoThumbsInstructionsSchema', - importPath: '../../alphalib/types/robots/video-thumbs.ts', - schema: robotVideoThumbsInstructionsSchema, - keys: ['count', 'format', 'width', 'height', 'resize_strategy', 'background', 'rotate'], - }, - execution: { - kind: 'single-step', - resultStepName: 'thumbnailed', - fixedValues: { - robot: '/video/thumbs', - result: true, - use: ':original', - }, - }, + '/text/speak': { + robot: '/text/speak', + meta: robotTextSpeakMeta, + schema: robotTextSpeakInstructionsSchema, + schemaImportName: 'robotTextSpeakInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/text-speak.ts', }, - { - className: 'VideoEncodeHlsCommand', - summary: 'Encode videos to HLS', - description: 'Encode a video into an HLS package', - details: - 'Runs the `builtin/encode-hls-video@latest` builtin template and downloads the HLS outputs into `--out`.', - paths: [['video', 'encode-hls']], - input: localFileInput, - outputMode: 'directory', - outputDescription: 'Write the HLS outputs into this directory', - outputRequired: true, - examples: [ - ['Encode a single video', 'transloadit video encode-hls --input input.mp4 --out dist/hls'], - [ - 'Process a directory recursively', - 'transloadit video encode-hls --input videos/ --out dist/hls --recursive', - ], - ], - execution: { - kind: 'template', - templateId: 'builtin/encode-hls-video@latest', - }, + '/video/thumbs': { + robot: '/video/thumbs', + meta: robotVideoThumbsMeta, + schema: robotVideoThumbsInstructionsSchema, + schemaImportName: 'robotVideoThumbsInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/video-thumbs.ts', }, - { - className: 'FileCompressCommand', - summary: 'Compress files into an archive', - description: 'Create an archive from one or more files', +} satisfies Record + +export const intentRecipeDefinitions = { + 'preview-generate': { + summary: 'Generate preview images for remote file URLs', + description: 'Generate a preview image for a remote file URL', details: - 'Runs `/file/compress` and writes the resulting archive to `--out`. Multiple inputs are bundled into one archive by default.', - paths: [['file', 'compress']], - input: { - kind: 'local-files', - description: 'Provide one or more input files or directories', - recursive: true, - deleteAfterProcessing: true, - reprocessStale: true, - defaultSingleAssembly: true, - }, - outputDescription: 'Write the generated archive to this path', + 'Imports a remote file with `/http/import`, then runs `/file/preview` and downloads the preview to `--out`.', + paths: ['preview', 'generate'], + inputMode: 'remote-url', + outputDescription: 'Write the generated preview image to this path', outputRequired: true, examples: [ [ - 'Create a ZIP archive', - 'transloadit file compress --input assets/ --format zip --out assets.zip', - ], - [ - 'Create a gzipped tarball', - 'transloadit file compress --input assets/ --format tar --gzip true --out assets.tar.gz', + 'Preview a remote PDF', + 'transloadit preview generate --input https://example.com/file.pdf --width 300 --height 200 --out preview.png', ], ], - schemaOptions: { - importName: 'robotFileCompressInstructionsSchema', - importPath: '../../alphalib/types/robots/file-compress.ts', - schema: robotFileCompressInstructionsSchema, - keys: ['format', 'gzip', 'password', 'compression_level', 'file_layout', 'archive_name'], - }, - execution: { - kind: 'single-step', - resultStepName: 'compressed', - fixedValues: { - robot: '/file/compress', - result: true, - use: { - steps: [':original'], - bundle_steps: true, - }, - }, - }, + schema: robotFilePreviewInstructionsSchema, + schemaImportName: 'robotFilePreviewInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/file-preview.ts', + resultStepName: 'preview', }, +} satisfies Record + +export const intentCatalog = [ + { kind: 'robot', robot: '/image/generate' }, + { kind: 'recipe', recipe: 'preview-generate' }, + { kind: 'robot', robot: '/image/bgremove' }, + { kind: 'robot', robot: '/image/optimize' }, + { kind: 'robot', robot: '/image/resize' }, + { kind: 'robot', robot: '/document/convert' }, + { kind: 'robot', robot: '/document/optimize' }, + { kind: 'robot', robot: '/document/autorotate' }, + { kind: 'robot', robot: '/document/thumbs', outputMode: 'directory' }, + { kind: 'robot', robot: '/audio/waveform' }, + { kind: 'robot', robot: '/text/speak' }, + { kind: 'robot', robot: '/video/thumbs', outputMode: 'directory' }, { - className: 'FileDecompressCommand', - summary: 'Extract archive contents', - description: 'Decompress an archive', - details: - 'Runs `/file/decompress` on each input archive and writes the extracted files to `--out`.', - paths: [['file', 'decompress']], - input: localFileInput, + kind: 'template', + templateId: 'builtin/encode-hls-video@latest', + paths: ['video', 'encode-hls'], outputMode: 'directory', - outputDescription: 'Write the extracted files to this directory', - outputRequired: true, - examples: [ - [ - 'Decompress a ZIP archive', - 'transloadit file decompress --input assets.zip --out extracted/', - ], - ], - schemaOptions: { - importName: 'robotFileDecompressInstructionsSchema', - importPath: '../../alphalib/types/robots/file-decompress.ts', - schema: robotFileDecompressInstructionsSchema, - keys: [], - }, - execution: { - kind: 'single-step', - resultStepName: 'decompressed', - fixedValues: { - robot: '/file/decompress', - result: true, - use: ':original', - }, - }, }, -] as const satisfies readonly IntentCommandSpec[] + { kind: 'robot', robot: '/file/compress', defaultSingleAssembly: true }, + { kind: 'robot', robot: '/file/decompress', outputMode: 'directory' }, +] satisfies IntentCatalogEntry[] From e56296a964e302efd2a02942f08d1c1b582777b5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 25 Mar 2026 23:46:52 +0100 Subject: [PATCH 05/44] fix(node-cli): tighten intent serialization and e2e smoke tests --- packages/node/scripts/test-intents-e2e.sh | 246 ++++++++++++++++++ packages/node/src/cli/commands/assemblies.ts | 150 ++++++----- packages/node/src/cli/intentRuntime.ts | 20 +- .../test/unit/cli/assemblies-create.test.ts | 95 ++++++- packages/node/test/unit/cli/intents.test.ts | 69 ++++- 5 files changed, 491 insertions(+), 89 deletions(-) create mode 100755 packages/node/scripts/test-intents-e2e.sh diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh new file mode 100755 index 00000000..c6e71e27 --- /dev/null +++ b/packages/node/scripts/test-intents-e2e.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORKDIR="${1:-/tmp/node-sdk-intent-e2e}" +OUTDIR="$WORKDIR/out" +LOGDIR="$WORKDIR/logs" +FIXTUREDIR="$WORKDIR/fixtures" +CLI=(node "$REPO_ROOT/packages/node/src/cli.ts") +PREVIEW_URL='https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' + +if [[ -f "$REPO_ROOT/.env" ]]; then + set -a + # shellcheck disable=SC1090 + source "$REPO_ROOT/.env" + set +a +fi + +if [[ -z "${TRANSLOADIT_KEY:-}" || -z "${TRANSLOADIT_SECRET:-}" ]]; then + echo "Missing TRANSLOADIT_KEY / TRANSLOADIT_SECRET. Expected them in $REPO_ROOT/.env or the environment." >&2 + exit 1 +fi + +require_command() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Missing required command: $command_name" >&2 + exit 1 + fi +} + +prepare_fixtures() { + require_command curl + require_command ffmpeg + require_command zip + + rm -rf "$WORKDIR" + mkdir -p "$OUTDIR" "$LOGDIR" "$FIXTUREDIR" + + cp "$REPO_ROOT/packages/node/examples/fixtures/berkley.jpg" "$FIXTUREDIR/input.jpg" + cp "$REPO_ROOT/packages/node/test/e2e/fixtures/testsrc.mp4" "$FIXTUREDIR/input.mp4" + printf 'Hello from Transloadit CLI intents\n' >"$FIXTUREDIR/input.txt" + zip -j "$FIXTUREDIR/input.zip" "$FIXTUREDIR/input.txt" >/dev/null + ffmpeg -f lavfi -i sine=frequency=1000:duration=1 -q:a 9 -acodec libmp3lame -y "$FIXTUREDIR/input.mp3" >/dev/null 2>&1 + curl -L --fail --silent --show-error -o "$FIXTUREDIR/input.pdf" "$PREVIEW_URL" +} + +verify_file_type() { + local path="$1" + local expected="$2" + + [[ -s "$path" ]] || return 1 + file "$path" | grep -F "$expected" >/dev/null +} + +verify_png() { + verify_file_type "$1" 'PNG image data' +} + +verify_jpeg() { + verify_file_type "$1" 'JPEG image data' +} + +verify_pdf() { + verify_file_type "$1" 'PDF document' +} + +verify_mp3() { + verify_file_type "$1" 'Audio file' +} + +verify_zip() { + verify_file_type "$1" 'Zip archive data' +} + +verify_document_thumbs() { + [[ -f "$1/in.png" ]] || return 1 + verify_png "$1/in.png" +} + +verify_video_thumbs() { + [[ -f "$1/in_0.jpg" ]] || return 1 + verify_jpeg "$1/in_0.jpg" +} + +verify_video_encode_hls() { + [[ -f "$1/high/in.mp4" ]] || return 1 + [[ -f "$1/low/in.mp4" ]] || return 1 + [[ -f "$1/mid/in.mp4" ]] || return 1 + [[ -f "$1/adaptive/my_playlist.m3u8" ]] || return 1 +} + +verify_file_decompress() { + [[ -f "$1/input.txt" ]] || return 1 + grep -F 'Hello from Transloadit CLI intents' "$1/input.txt" >/dev/null +} + +run_case() { + local name="$1" + local output_path="$2" + local verifier="$3" + shift 3 + + local logfile="$LOGDIR/${name}.log" + rm -rf "$output_path" + mkdir -p "$(dirname "$output_path")" + + set +e + "${CLI[@]}" "$@" >"$logfile" 2>&1 + local exit_code=$? + set -e + + local verdict='FAIL' + local detail='' + + if [[ $exit_code -eq 0 ]] && "$verifier" "$output_path"; then + verdict='OK' + if [[ -f "$output_path" ]]; then + detail="$(file "$output_path" | sed 's#^.*: ##' | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g')" + else + detail="$(find "$output_path" -type f | sed "s#^$output_path/##" | sort | tr '\n' ',' | sed 's/,$//')" + fi + else + if [[ -s "$logfile" ]]; then + detail="$(tail -n 8 "$logfile" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g' | cut -c1-220)" + else + detail='No output captured' + fi + fi + + printf '%s\t%s\t%s\t%s\n' "$name" "$exit_code" "$verdict" "$detail" +} + +prepare_fixtures + +RESULTS_TSV="$WORKDIR/results.tsv" +printf 'command\texit\tverdict\tdetail\n' >"$RESULTS_TSV" + +run_case image-generate "$OUTDIR/image-generate.png" verify_png \ + image generate \ + --prompt 'A small red bicycle on a cream background, studio lighting' \ + --model 'google/nano-banana' \ + --out "$OUTDIR/image-generate.png" \ + >>"$RESULTS_TSV" + +run_case preview-generate "$OUTDIR/preview-generate.png" verify_png \ + preview generate \ + --input "$PREVIEW_URL" \ + --width 300 \ + --out "$OUTDIR/preview-generate.png" \ + >>"$RESULTS_TSV" + +run_case image-remove-background "$OUTDIR/image-remove-background.png" verify_png \ + image remove-background \ + --input "$FIXTUREDIR/input.jpg" \ + --out "$OUTDIR/image-remove-background.png" \ + >>"$RESULTS_TSV" + +run_case image-optimize "$OUTDIR/image-optimize.jpg" verify_jpeg \ + image optimize \ + --input "$FIXTUREDIR/input.jpg" \ + --out "$OUTDIR/image-optimize.jpg" \ + >>"$RESULTS_TSV" + +run_case image-resize "$OUTDIR/image-resize.jpg" verify_jpeg \ + image resize \ + --input "$FIXTUREDIR/input.jpg" \ + --width 200 \ + --out "$OUTDIR/image-resize.jpg" \ + >>"$RESULTS_TSV" + +run_case document-convert "$OUTDIR/document-convert.pdf" verify_pdf \ + document convert \ + --input "$FIXTUREDIR/input.txt" \ + --format pdf \ + --out "$OUTDIR/document-convert.pdf" \ + >>"$RESULTS_TSV" + +run_case document-optimize "$OUTDIR/document-optimize.pdf" verify_pdf \ + document optimize \ + --input "$FIXTUREDIR/input.pdf" \ + --out "$OUTDIR/document-optimize.pdf" \ + >>"$RESULTS_TSV" + +run_case document-auto-rotate "$OUTDIR/document-auto-rotate.pdf" verify_pdf \ + document auto-rotate \ + --input "$FIXTUREDIR/input.pdf" \ + --out "$OUTDIR/document-auto-rotate.pdf" \ + >>"$RESULTS_TSV" + +run_case document-thumbs "$OUTDIR/document-thumbs" verify_document_thumbs \ + document thumbs \ + --input "$FIXTUREDIR/input.pdf" \ + --out "$OUTDIR/document-thumbs" \ + >>"$RESULTS_TSV" + +run_case audio-waveform "$OUTDIR/audio-waveform.png" verify_png \ + audio waveform \ + --input "$FIXTUREDIR/input.mp3" \ + --out "$OUTDIR/audio-waveform.png" \ + >>"$RESULTS_TSV" + +run_case text-speak "$OUTDIR/text-speak.mp3" verify_mp3 \ + text speak \ + --prompt 'Hello from the Transloadit Node CLI intents test.' \ + --provider aws \ + --out "$OUTDIR/text-speak.mp3" \ + >>"$RESULTS_TSV" + +run_case video-thumbs "$OUTDIR/video-thumbs" verify_video_thumbs \ + video thumbs \ + --input "$FIXTUREDIR/input.mp4" \ + --out "$OUTDIR/video-thumbs" \ + >>"$RESULTS_TSV" + +run_case video-encode-hls "$OUTDIR/video-encode-hls" verify_video_encode_hls \ + video encode-hls \ + --input "$FIXTUREDIR/input.mp4" \ + --out "$OUTDIR/video-encode-hls" \ + >>"$RESULTS_TSV" + +run_case file-compress "$OUTDIR/file-compress.zip" verify_zip \ + file compress \ + --input "$FIXTUREDIR/input.txt" \ + --format zip \ + --out "$OUTDIR/file-compress.zip" \ + >>"$RESULTS_TSV" + +run_case file-decompress "$OUTDIR/file-decompress" verify_file_decompress \ + file decompress \ + --input "$FIXTUREDIR/input.zip" \ + --out "$OUTDIR/file-decompress" \ + >>"$RESULTS_TSV" + +column -t -s $'\t' "$RESULTS_TSV" + +if awk -F '\t' 'NR > 1 && $3 != "OK" { exit 1 }' "$RESULTS_TSV"; then + echo + echo "All intent commands passed. Fixtures, outputs, and logs are in $WORKDIR" +else + echo + echo "One or more intent commands failed. Inspect $LOGDIR for details." >&2 + exit 1 +fi diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index ff636e3a..8f78bbb9 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1,9 +1,11 @@ +import { randomUUID } from 'node:crypto' import EventEmitter from 'node:events' import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' import process from 'node:process' -import type { Readable, Writable } from 'node:stream' +import type { Readable } from 'node:stream' +import { Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { setTimeout as delay } from 'node:timers/promises' import tty from 'node:tty' @@ -361,6 +363,18 @@ async function myStat( return await fsp.stat(filepath) } +function createPlaceholderOutStream(outpath: string, mtime: Date): OutStream { + const outstream = new Writable({ + write(_chunk, _encoding, callback) { + callback() + }, + }) as OutStream + outstream.path = outpath + outstream.mtime = mtime + outstream.on('error', () => {}) + return outstream +} + function dirProvider(output: string): OutstreamProvider { return async (inpath, indir = process.cwd()) => { // Inputless assemblies can still write into a directory, but output paths are derived from @@ -375,34 +389,19 @@ function dirProvider(output: string): OutstreamProvider { let relpath = path.relative(indir, inpath) relpath = relpath.replace(/^(\.\.\/)+/, '') const outpath = path.join(output, relpath) - const outdir = path.dirname(outpath) - - await fsp.mkdir(outdir, { recursive: true }) const [, stats] = await tryCatch(fsp.stat(outpath)) const mtime = stats?.mtime ?? new Date(0) - const outstream = fs.createWriteStream(outpath) as OutStream - // Attach a no-op error handler to prevent unhandled errors if stream is destroyed - // before being consumed (e.g., due to output collision detection) - outstream.on('error', () => {}) - outstream.mtime = mtime - return outstream + return createPlaceholderOutStream(outpath, mtime) } } function fileProvider(output: string): OutstreamProvider { - const dirExistsP = fsp.mkdir(path.dirname(output), { recursive: true }) return async (_inpath) => { - await dirExistsP if (output === '-') return process.stdout as OutStream const [, stats] = await tryCatch(fsp.stat(output)) const mtime = stats?.mtime ?? new Date(0) - const outstream = fs.createWriteStream(output) as OutStream - // Attach a no-op error handler to prevent unhandled errors if stream is destroyed - // before being consumed (e.g., due to output collision detection) - outstream.on('error', () => {}) - outstream.mtime = mtime - return outstream + return createPlaceholderOutStream(output, mtime) } } @@ -410,6 +409,26 @@ function nullProvider(): OutstreamProvider { return async (_inpath) => null } +async function downloadResultToFile( + resultUrl: string, + outPath: string, + signal: AbortSignal, +): Promise { + await fsp.mkdir(path.dirname(outPath), { recursive: true }) + + const tempPath = path.join(path.dirname(outPath), `.${path.basename(outPath)}.${randomUUID()}.tmp`) + const outStream = fs.createWriteStream(tempPath) as OutStream + outStream.on('error', () => {}) + + const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal }), outStream)) + if (dlErr) { + await fsp.rm(tempPath, { force: true }) + throw dlErr + } + + await fsp.rename(tempPath, outPath) +} + class MyEventEmitter extends EventEmitter { protected hasEnded: boolean @@ -934,6 +953,14 @@ export async function create( } } + const inputStats = await Promise.all( + inputs.map(async (input) => { + if (input === '-') return null + return await myStat(process.stdin, input) + }), + ) + const hasDirectoryInput = inputStats.some((stat) => stat?.isDirectory() === true) + return new Promise((resolve, reject) => { const params: CreateAssemblyParams = ( effectiveStepsData @@ -981,13 +1008,6 @@ export async function create( inStream?.on('error', () => {}) let superceded = false - // When writing to a file path (non-directory output), we treat finish as a supersede signal. - // Directory-output multi-download mode does not use a single shared outstream. - const markSupersededOnFinish = (stream: OutStream) => { - stream.on('finish', () => { - superceded = true - }) - } const createOptions: CreateAssemblyOptions = { params, @@ -1062,38 +1082,53 @@ export async function create( } } + const shouldGroupByInput = inPath != null && (hasDirectoryInput || inputs.length > 1) + + const resolveDirectoryBaseDir = (): string => { + if (!shouldGroupByInput || inPath == null) { + return resolvedOutput as string + } + + if (hasDirectoryInput && outPath != null) { + const mappedRelative = path.relative(resolvedOutput as string, outPath) + const mappedDir = path.dirname(mappedRelative) + const mappedStem = path.parse(mappedRelative).name + return path.join( + resolvedOutput as string, + mappedDir === '.' ? '' : mappedDir, + mappedStem, + ) + } + + return path.join(resolvedOutput as string, path.parse(path.basename(inPath)).name) + } + if (resolvedOutput != null && !superceded) { // Directory output: - // - For single-result, input-backed jobs, preserve existing behavior (write to mapped file path). - // - Otherwise (multi-result or inputless), download all results into a directory structure. - if (outIsDirectory && (inPath == null || allFiles.length !== 1 || outPath == null)) { - let baseDir = resolvedOutput - if (inPath != null) { - let relpath = path.relative(process.cwd(), inPath) - relpath = relpath.replace(/^(\.\.\/)+/, '') - baseDir = path.join(resolvedOutput, path.dirname(relpath), path.parse(relpath).name) - } + // - Single-step results write directly into the output directory when possible. + // - Multiple steps use per-step subdirectories to avoid collisions and expose structure. + if (outIsDirectory) { + const baseDir = resolveDirectoryBaseDir() await fsp.mkdir(baseDir, { recursive: true }) + const shouldUseStepDirectories = entries.length > 1 for (const { stepName, file } of allFiles) { const resultUrl = getFileUrl(file) if (!resultUrl) continue - const stepDir = path.join(baseDir, stepName) - await fsp.mkdir(stepDir, { recursive: true }) + const targetDir = shouldUseStepDirectories ? path.join(baseDir, stepName) : baseDir + await fsp.mkdir(targetDir, { recursive: true }) const rawName = file.name ?? (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? `${stepName}_result` const safeName = sanitizeName(rawName) - const targetPath = await ensureUniquePath(path.join(stepDir, safeName)) + const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) outputctl.debug('DOWNLOADING') - const outStream = fs.createWriteStream(targetPath) as OutStream - outStream.on('error', () => {}) const [dlErr] = await tryCatch( - pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream), + downloadResultToFile(resultUrl, targetPath, abortController.signal), ) if (dlErr) { if (dlErr.name === 'AbortError') continue @@ -1101,39 +1136,13 @@ export async function create( throw dlErr } } - } else if (!outIsDirectory && outPath != null) { - const first = allFiles[0] - const resultUrl = first ? getFileUrl(first.file) : null - if (resultUrl) { - outputctl.debug('DOWNLOADING') - const outStream = fs.createWriteStream(outPath) as OutStream - outStream.on('error', () => {}) - outStream.mtime = outMtime - markSupersededOnFinish(outStream) - - const [dlErr] = await tryCatch( - pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream), - ) - if (dlErr) { - if (dlErr.name !== 'AbortError') { - outputctl.error(dlErr.message) - throw dlErr - } - } - } - } else if (outIsDirectory && outPath != null) { - // Single-result, input-backed job: preserve existing file mapping in outdir. + } else if (outPath != null) { const first = allFiles[0] const resultUrl = first ? getFileUrl(first.file) : null if (resultUrl) { outputctl.debug('DOWNLOADING') - const outStream = fs.createWriteStream(outPath) as OutStream - outStream.on('error', () => {}) - outStream.mtime = outMtime - markSupersededOnFinish(outStream) - const [dlErr] = await tryCatch( - pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream), + downloadResultToFile(resultUrl, outPath, abortController.signal), ) if (dlErr) { if (dlErr.name !== 'AbortError') { @@ -1243,10 +1252,7 @@ export async function create( outputctl.debug(`DOWNLOADING ${stepResult.name} to ${outPath}`) const [dlErr] = await tryCatch( - pipeline( - got.stream(resultUrl, { signal: abortController.signal }), - fs.createWriteStream(outPath), - ), + downloadResultToFile(resultUrl, outPath, abortController.signal), ) if (dlErr) { if (dlErr.name === 'AbortError') continue diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index f4546951..5108903d 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -47,6 +47,22 @@ export function parseIntentStep({ input[fieldSpec.name] = coerceIntentFieldValue(fieldSpec.kind, rawValue) } - const parsed = schema.parse(input) - return parsed as z.input + schema.parse(input) + + const normalizedInput: Record = { ...fixedValues } + const shape = schema.shape as Record + + for (const fieldSpec of fieldSpecs) { + const rawValue = rawValues[fieldSpec.name] + if (rawValue == null) continue + + const fieldSchema = shape[fieldSpec.name] + if (fieldSchema == null) { + throw new Error(`Missing schema definition for intent field "${fieldSpec.name}"`) + } + + normalizedInput[fieldSpec.name] = fieldSchema.parse(input[fieldSpec.name]) + } + + return normalizedInput as z.input } diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 921f740a..40626c12 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import nock from 'nock' @@ -76,7 +76,7 @@ describe('assemblies create', () => { expect(await readFile(outputPath, 'utf8')).toBe('bundle-contents') }) - it('treats explicit directory outputs as directories even when the path does not exist yet', async () => { + it('writes single-input directory outputs using result filenames', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const tempDir = await createTempDir('transloadit-outdir-') @@ -125,16 +125,89 @@ describe('assemblies create', () => { }), ) - let relpath = path.relative(process.cwd(), inputPath) - relpath = relpath.replace(/^(\.\.\/)+/, '') - const resultsDir = path.join( - outputDir, - path.dirname(relpath), - path.parse(relpath).name, - 'thumbs', + expect(await readFile(path.join(outputDir, 'one.jpg'), 'utf8')).toBe('one') + expect(await readFile(path.join(outputDir, 'two.jpg'), 'utf8')).toBe('two') + }) + + it('uses the actual result filename for single-result directory outputs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-single-result-outdir-') + const inputPath = path.join(tempDir, 'archive.zip') + const outputDir = path.join(tempDir, 'extracted') + + await writeFile(inputPath, 'zip-data') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-3' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + decompressed: [{ url: 'http://downloads.test/input.txt', name: 'input.txt' }], + }, + }), + } + + nock('http://downloads.test').get('/input.txt').reply(200, 'hello') + + await expect( + create(output, client as never, { + inputs: [inputPath], + output: outputDir, + stepsData: { + decompressed: { + robot: '/file/decompress', + result: true, + use: ':original', + }, + }, + outputMode: 'directory', + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + expect(await readFile(path.join(outputDir, 'input.txt'), 'utf8')).toBe('hello') + }) + + it('does not create an empty output file when assembly creation fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-failed-create-') + const inputPath = path.join(tempDir, 'image.jpg') + const outputPath = path.join(tempDir, 'resized.jpg') + + await writeFile(inputPath, 'image-data') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockRejectedValue(new Error('boom')), + } + + await expect( + create(output, client as never, { + inputs: [inputPath], + output: outputPath, + stepsData: { + resized: { + robot: '/image/resize', + result: true, + use: ':original', + width: 200, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: true, + }), ) - expect(await readFile(path.join(resultsDir, 'one.jpg'), 'utf8')).toBe('one') - expect(await readFile(path.join(resultsDir, 'two.jpg'), 'utf8')).toBe('two') + await expect(stat(outputPath)).rejects.toMatchObject({ + code: 'ENOENT', + }) }) }) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 01ae70a0..312583d2 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -185,7 +185,7 @@ describe('intent commands', () => { ) }) - it('allows audio waveform to use the schema default style', async () => { + it('omits schema defaults from generated intent steps', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -206,12 +206,11 @@ describe('intent commands', () => { inputs: ['podcast.mp3'], output: 'waveform.png', stepsData: { - waveformed: expect.objectContaining({ + waveformed: { robot: '/audio/waveform', result: true, use: ':original', - style: 'v0', - }), + }, }, }), ) @@ -358,4 +357,66 @@ describe('intent commands', () => { }), ) }) + + it('omits nullable defaults like file compress password when not provided', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main(['file', 'compress', '--input', 'assets', '--format', 'zip', '--out', 'assets.zip']) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + compressed: { + robot: '/file/compress', + result: true, + format: 'zip', + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + }, + }), + ) + }) + + it('omits numeric defaults like video thumbs rotate when not provided', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main(['video', 'thumbs', '--input', 'demo.mp4', '--out', 'thumbs']) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + thumbnailed: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + }, + }), + ) + }) }) From d9bc8ad334889fff2b63f24bd638b4a9c1942e99 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 25 Mar 2026 23:59:23 +0100 Subject: [PATCH 06/44] refactor(node-cli): simplify intent generation internals --- .../node/scripts/generate-intent-commands.ts | 27 ++++++++++--------- packages/node/src/cli/commands/assemblies.ts | 15 +++++------ packages/node/src/cli/intentRuntime.ts | 12 ++------- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index a8d04498..0f3f4021 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -304,8 +304,12 @@ function inferDetails( return `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.` } -function inferLocalFilesInput(entry: RobotIntentCatalogEntry): ResolvedIntentLocalFilesInput { - if (entry.defaultSingleAssembly) { +function inferLocalFilesInput({ + defaultSingleAssembly = false, +}: { + defaultSingleAssembly?: boolean +}): ResolvedIntentLocalFilesInput { + if (defaultSingleAssembly) { return { kind: 'local-files', description: 'Provide one or more input files or directories', @@ -337,7 +341,7 @@ function inferInputSpec( return { kind: 'none' } } - return inferLocalFilesInput(entry) + return inferLocalFilesInput({ defaultSingleAssembly: entry.defaultSingleAssembly }) } function inferFixedValues( @@ -527,7 +531,7 @@ function resolveTemplateIntentSpec( entry: IntentCatalogEntry & { kind: 'template' }, ): ResolvedIntentCommandSpec { const outputMode = inferOutputMode(entry) - const input = inferLocalFilesInput({ kind: 'robot', robot: '/file/decompress', outputMode }) + const input = inferLocalFilesInput({}) return { className: inferClassName(entry.paths), @@ -780,11 +784,11 @@ function formatLocalValidation(spec: ResolvedIntentCommandSpec, commandLabel: st return lines.join('\n') } -function formatRunBody(spec: ResolvedIntentCommandSpec): string { +function formatRunBody( + spec: ResolvedIntentCommandSpec, + fieldSpecs: GeneratedSchemaField[], +): string { const schemaSpec = spec.schemaSpec - const fieldSpecs = - schemaSpec == null ? [] : collectSchemaFields(schemaSpec, resolveFixedValues(spec), spec.input) - if (spec.execution.kind === 'single-step') { const parseStep = ` const step = parseIntentStep({ schema: ${schemaSpec?.importName}, @@ -890,13 +894,12 @@ function generateImports(specs: ResolvedIntentCommandSpec[]): string { } function generateClass(spec: ResolvedIntentCommandSpec): string { + const fixedValues = resolveFixedValues(spec) const fieldSpecs = - spec.schemaSpec == null - ? [] - : collectSchemaFields(spec.schemaSpec, resolveFixedValues(spec), spec.input) + spec.schemaSpec == null ? [] : collectSchemaFields(spec.schemaSpec, fixedValues, spec.input) const schemaFields = formatSchemaFields(fieldSpecs) const inputOptions = formatInputOptions(spec) - const runBody = formatRunBody(spec) + const runBody = formatRunBody(spec, fieldSpecs) return ` export class ${spec.className} extends AuthenticatedCommand { diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 8f78bbb9..2b3970e4 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -416,7 +416,10 @@ async function downloadResultToFile( ): Promise { await fsp.mkdir(path.dirname(outPath), { recursive: true }) - const tempPath = path.join(path.dirname(outPath), `.${path.basename(outPath)}.${randomUUID()}.tmp`) + const tempPath = path.join( + path.dirname(outPath), + `.${path.basename(outPath)}.${randomUUID()}.tmp`, + ) const outStream = fs.createWriteStream(tempPath) as OutStream outStream.on('error', () => {}) @@ -999,7 +1002,7 @@ export async function create( async function processAssemblyJob( inPath: string | null, outPath: string | null, - outMtime: Date | undefined, + _outMtime: Date | undefined, ): Promise { outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`) @@ -1007,7 +1010,7 @@ export async function create( const inStream = inPath ? fs.createReadStream(inPath) : null inStream?.on('error', () => {}) - let superceded = false + const superceded = false const createOptions: CreateAssemblyOptions = { params, @@ -1093,11 +1096,7 @@ export async function create( const mappedRelative = path.relative(resolvedOutput as string, outPath) const mappedDir = path.dirname(mappedRelative) const mappedStem = path.parse(mappedRelative).name - return path.join( - resolvedOutput as string, - mappedDir === '.' ? '' : mappedDir, - mappedStem, - ) + return path.join(resolvedOutput as string, mappedDir === '.' ? '' : mappedDir, mappedStem) } return path.join(resolvedOutput as string, path.parse(path.basename(inPath)).name) diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 5108903d..6a1fc9e1 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -47,21 +47,13 @@ export function parseIntentStep({ input[fieldSpec.name] = coerceIntentFieldValue(fieldSpec.kind, rawValue) } - schema.parse(input) - + const parsed = schema.parse(input) as Record const normalizedInput: Record = { ...fixedValues } - const shape = schema.shape as Record for (const fieldSpec of fieldSpecs) { const rawValue = rawValues[fieldSpec.name] if (rawValue == null) continue - - const fieldSchema = shape[fieldSpec.name] - if (fieldSchema == null) { - throw new Error(`Missing schema definition for intent field "${fieldSpec.name}"`) - } - - normalizedInput[fieldSpec.name] = fieldSchema.parse(input[fieldSpec.name]) + normalizedInput[fieldSpec.name] = parsed[fieldSpec.name] } return normalizedInput as z.input From 2de42415f86f40fedbf73d422538989d8b15323d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 26 Mar 2026 12:21:01 +0100 Subject: [PATCH 07/44] chore(mcp-server): drop local registry artifacts --- .gitignore | 2 ++ packages/mcp-server/server.json | 59 --------------------------------- 2 files changed, 2 insertions(+), 59 deletions(-) delete mode 100644 packages/mcp-server/server.json diff --git a/.gitignore b/.gitignore index 62171e1d..8ec81799 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ packages/transloadit/README.md packages/transloadit/CHANGELOG.md packages/transloadit/LICENSE package.tgz +packages/mcp-server/.mcpregistry_github_token +packages/mcp-server/.mcpregistry_registry_token diff --git a/packages/mcp-server/server.json b/packages/mcp-server/server.json deleted file mode 100644 index affac95c..00000000 --- a/packages/mcp-server/server.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.transloadit/mcp-server", - "title": "Transloadit Media Processing", - "description": "Process video, audio, images, and documents with 86+ cloud media processing robots.", - "version": "0.3.7", - "websiteUrl": "https://transloadit.com/docs/sdks/mcp-server/", - "repository": { - "url": "https://github.com/transloadit/node-sdk", - "source": "github", - "subfolder": "packages/mcp-server" - }, - "packages": [ - { - "registryType": "npm", - "identifier": "@transloadit/mcp-server", - "version": "0.3.6", - "runtimeHint": "npx", - "packageArguments": [ - { - "type": "positional", - "value": "stdio", - "description": "Transport mode for the MCP server" - } - ], - "transport": { - "type": "stdio" - }, - "environmentVariables": [ - { - "name": "TRANSLOADIT_KEY", - "description": "Your Transloadit Auth Key from https://transloadit.com/c/-/api-credentials", - "isRequired": true, - "isSecret": false - }, - { - "name": "TRANSLOADIT_SECRET", - "description": "Your Transloadit Auth Secret from https://transloadit.com/c/-/api-credentials", - "isRequired": true, - "isSecret": true - } - ] - } - ], - "remotes": [ - { - "type": "streamable-http", - "url": "https://api2.transloadit.com/mcp", - "headers": [ - { - "name": "Authorization", - "description": "Bearer token obtained via the authenticate tool, or set TRANSLOADIT_KEY and TRANSLOADIT_SECRET env vars with the self-hosted package instead", - "isRequired": false, - "isSecret": true - } - ] - } - ] -} From 4e51188a456f88265ceb08c91757f948a9184146 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 26 Mar 2026 12:45:47 +0100 Subject: [PATCH 08/44] fix(node-cli): address council review findings --- .../node/scripts/generate-intent-commands.ts | 136 +++++++++++--- packages/node/src/cli/commands/assemblies.ts | 103 ++++++----- .../src/cli/commands/generated-intents.ts | 88 +++++++-- packages/node/src/cli/intentRuntime.ts | 32 +++- .../test/unit/cli/assemblies-create.test.ts | 169 +++++++++++++++++- packages/node/test/unit/cli/intents.test.ts | 169 ++++++++++++++++++ 6 files changed, 618 insertions(+), 79 deletions(-) diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 0f3f4021..8c6edc55 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -29,7 +29,7 @@ import { robotIntentDefinitions, } from '../src/cli/intentCommandSpecs.ts' -type GeneratedFieldKind = 'boolean' | 'number' | 'string' +type GeneratedFieldKind = 'auto' | 'boolean' | 'number' | 'string' interface GeneratedSchemaField { description?: string @@ -48,6 +48,7 @@ interface ResolvedIntentLocalFilesInput { deleteAfterProcessing?: boolean description: string kind: 'local-files' + requiredFieldForInputless?: string recursive?: boolean reprocessStale?: boolean } @@ -227,7 +228,7 @@ function getFieldKind(schema: unknown): GeneratedFieldKind { const [kind] = optionKinds if (kind != null) return kind } - return 'string' + return 'auto' } throw new Error('Unsupported schema type') @@ -257,7 +258,9 @@ function inferInputMode( const shape = (definition.schema as ZodObject>).shape if ('prompt' in shape) { - return 'none' + const promptSchema = shape.prompt + const { required } = unwrapSchema(promptSchema) + return required ? 'none' : 'local-files' } return 'local-files' @@ -306,8 +309,10 @@ function inferDetails( function inferLocalFilesInput({ defaultSingleAssembly = false, + requiredFieldForInputless, }: { defaultSingleAssembly?: boolean + requiredFieldForInputless?: string }): ResolvedIntentLocalFilesInput { if (defaultSingleAssembly) { return { @@ -317,6 +322,7 @@ function inferLocalFilesInput({ deleteAfterProcessing: true, reprocessStale: true, defaultSingleAssembly: true, + requiredFieldForInputless, } } @@ -329,6 +335,7 @@ function inferLocalFilesInput({ reprocessStale: true, allowSingleAssembly: true, allowConcurrency: true, + requiredFieldForInputless, } } @@ -341,7 +348,14 @@ function inferInputSpec( return { kind: 'none' } } - return inferLocalFilesInput({ defaultSingleAssembly: entry.defaultSingleAssembly }) + const shape = (definition.schema as ZodObject>).shape + const requiredFieldForInputless = + 'prompt' in shape && !unwrapSchema(shape.prompt).required ? 'prompt' : undefined + + return inferLocalFilesInput({ + defaultSingleAssembly: entry.defaultSingleAssembly, + requiredFieldForInputless, + }) } function inferFixedValues( @@ -349,6 +363,9 @@ function inferFixedValues( definition: RobotIntentDefinition, inputMode: Exclude, ): Record { + const shape = (definition.schema as ZodObject>).shape + const promptIsOptional = 'prompt' in shape && !unwrapSchema(shape.prompt).required + if (entry.defaultSingleAssembly) { return { robot: definition.robot, @@ -361,6 +378,13 @@ function inferFixedValues( } if (inputMode === 'local-files') { + if (promptIsOptional) { + return { + robot: definition.robot, + result: true, + } + } + return { robot: definition.robot, result: true, @@ -439,6 +463,7 @@ function inferExamples( paths: string[], inputMode: IntentInputMode, outputMode: IntentOutputMode, + fieldSpecs: GeneratedSchemaField[], ): Array<[string, string]> { const parts = ['transloadit', ...paths] @@ -454,11 +479,45 @@ function inferExamples( parts.push('--input', 'https://example.com/file.pdf') } + if (definition != null) { + for (const fieldSpec of fieldSpecs) { + if (!fieldSpec.required) continue + if (fieldSpec.name === 'prompt' && inputMode === 'none') continue + + const exampleValue = inferExampleValue(definition, fieldSpec) + if (exampleValue == null) continue + parts.push(fieldSpec.optionFlags, exampleValue) + } + } + parts.push('--out', guessOutputPath(definition, paths, outputMode)) return [['Run the command', parts.join(' ')]] } +function inferExampleValue( + definition: RobotIntentDefinition, + fieldSpec: GeneratedSchemaField, +): string | null { + if (fieldSpec.name === 'aspect_ratio') return '1:1' + if (fieldSpec.name === 'format') { + if (definition.robot === '/document/convert') return 'pdf' + if (definition.robot === '/file/compress') return 'zip' + if (definition.robot === '/video/thumbs') return 'jpg' + return 'png' + } + if (fieldSpec.name === 'model') return 'flux-schnell' + if (fieldSpec.name === 'prompt') return JSON.stringify(guessPromptExample(definition.robot)) + if (fieldSpec.name === 'provider') return 'aws' + if (fieldSpec.name === 'target_language') return 'en-US' + if (fieldSpec.name === 'voice') return 'female-1' + + if (fieldSpec.kind === 'boolean') return 'true' + if (fieldSpec.kind === 'number') return '1' + + return 'value' +} + function collectSchemaFields( schemaSpec: ResolvedIntentSchemaSpec, fixedValues: Record, @@ -503,27 +562,30 @@ function resolveRobotIntentSpec(entry: RobotIntentCatalogEntry): ResolvedIntentC const inputMode = inferInputMode(entry, definition) const outputMode = inferOutputMode(entry) const input = inferInputSpec(entry, definition) + const schemaSpec = { + importName: definition.schemaImportName, + importPath: definition.schemaImportPath, + schema: definition.schema as ZodObject>, + } satisfies ResolvedIntentSchemaSpec + const execution = { + kind: 'single-step', + resultStepName: inferResultStepName(definition.robot), + fixedValues: inferFixedValues(entry, definition, inputMode), + } satisfies ResolvedIntentSingleStepExecution + const fieldSpecs = collectSchemaFields(schemaSpec, execution.fixedValues, input) return { className: inferClassName(paths), description: inferDescription(definition), details: inferDetails(definition, inputMode, outputMode, entry.defaultSingleAssembly === true), - examples: inferExamples(definition, paths, inputMode, outputMode), + examples: inferExamples(definition, paths, inputMode, outputMode, fieldSpecs), input, outputDescription: inferOutputDescription(inputMode, outputMode), outputMode, outputRequired: true, paths, - schemaSpec: { - importName: definition.schemaImportName, - importPath: definition.schemaImportPath, - schema: definition.schema as ZodObject>, - }, - execution: { - kind: 'single-step', - resultStepName: inferResultStepName(definition.robot), - fixedValues: inferFixedValues(entry, definition, inputMode), - }, + schemaSpec, + execution, } } @@ -754,12 +816,20 @@ function formatLocalValidation(spec: ResolvedIntentCommandSpec, commandLabel: st throw new Error('Expected a local-files input spec') } - const lines = [ - ' if ((this.inputs ?? []).length === 0) {', - ` this.output.error('${commandLabel} requires at least one --input')`, - ' return 1', - ' }', - ] + const lines = + spec.input.requiredFieldForInputless == null + ? [ + ' if ((this.inputs ?? []).length === 0) {', + ` this.output.error('${commandLabel} requires at least one --input')`, + ' return 1', + ' }', + ] + : [ + ` if ((this.inputs ?? []).length === 0 && this.${toCamelCase(spec.input.requiredFieldForInputless)} == null) {`, + ` this.output.error('${commandLabel} requires --input or --${toKebabCase(spec.input.requiredFieldForInputless)}')`, + ' return 1', + ' }', + ] if (spec.input.allowWatch && spec.input.allowSingleAssembly) { lines.push( @@ -784,6 +854,28 @@ function formatLocalValidation(spec: ResolvedIntentCommandSpec, commandLabel: st return lines.join('\n') } +function formatSingleStepFixedValues(spec: ResolvedIntentCommandSpec): string { + if (spec.execution.kind !== 'single-step') { + throw new Error('Expected a single-step execution spec') + } + + if (spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null) { + const baseFixedValues = JSON.stringify(spec.execution.fixedValues, null, 6).replace( + /\n/g, + '\n ', + ) + + return `(this.inputs ?? []).length > 0 + ? { + ...${baseFixedValues}, + use: ':original', + } + : ${baseFixedValues}` + } + + return JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ') +} + function formatRunBody( spec: ResolvedIntentCommandSpec, fieldSpecs: GeneratedSchemaField[], @@ -792,7 +884,7 @@ function formatRunBody( if (spec.execution.kind === 'single-step') { const parseStep = ` const step = parseIntentStep({ schema: ${schemaSpec?.importName}, - fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ')}, + fixedValues: ${formatSingleStepFixedValues(spec)}, fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, rawValues: ${formatRawValues(fieldSpecs)}, })` diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 2b3970e4..59a910aa 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -320,6 +320,7 @@ interface JobEmitterOptions { allowOutputCollisions?: boolean recursive?: boolean outstreamProvider: OutstreamProvider + singleAssembly?: boolean streamRegistry: StreamRegistry watch?: boolean reprocessStale?: boolean @@ -786,6 +787,7 @@ function makeJobEmitter( allowOutputCollisions, recursive, outstreamProvider, + singleAssembly, streamRegistry, watch: watchOption, reprocessStale, @@ -853,7 +855,7 @@ function makeJobEmitter( }) const conflictFilter = allowOutputCollisions ? passthroughJobs : detectConflicts - const staleFilter = reprocessStale ? passthroughJobs : dismissStaleJobs + const staleFilter = reprocessStale || singleAssembly ? passthroughJobs : dismissStaleJobs return staleFilter(conflictFilter(emitter)) } @@ -987,6 +989,7 @@ export async function create( recursive, watch: watchOption, outstreamProvider, + singleAssembly, streamRegistry, reprocessStale, }) @@ -1086,6 +1089,7 @@ export async function create( } const shouldGroupByInput = inPath != null && (hasDirectoryInput || inputs.length > 1) + const useIntentDirectoryLayout = outputMode === 'directory' const resolveDirectoryBaseDir = (): string => { if (!shouldGroupByInput || inPath == null) { @@ -1102,53 +1106,72 @@ export async function create( return path.join(resolvedOutput as string, path.parse(path.basename(inPath)).name) } + const downloadResultFile = async (resultUrl: string, targetPath: string): Promise => { + outputctl.debug('DOWNLOADING') + const [dlErr] = await tryCatch( + downloadResultToFile(resultUrl, targetPath, abortController.signal), + ) + if (dlErr) { + if (dlErr.name === 'AbortError') return + outputctl.error(dlErr.message) + throw dlErr + } + } + if (resolvedOutput != null && !superceded) { - // Directory output: - // - Single-step results write directly into the output directory when possible. - // - Multiple steps use per-step subdirectories to avoid collisions and expose structure. if (outIsDirectory) { - const baseDir = resolveDirectoryBaseDir() - await fsp.mkdir(baseDir, { recursive: true }) - const shouldUseStepDirectories = entries.length > 1 - - for (const { stepName, file } of allFiles) { - const resultUrl = getFileUrl(file) - if (!resultUrl) continue - - const targetDir = shouldUseStepDirectories ? path.join(baseDir, stepName) : baseDir - await fsp.mkdir(targetDir, { recursive: true }) - - const rawName = - file.name ?? - (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? - `${stepName}_result` - const safeName = sanitizeName(rawName) - const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) - - outputctl.debug('DOWNLOADING') - const [dlErr] = await tryCatch( - downloadResultToFile(resultUrl, targetPath, abortController.signal), - ) - if (dlErr) { - if (dlErr.name === 'AbortError') continue - outputctl.error(dlErr.message) - throw dlErr + if (useIntentDirectoryLayout || outPath == null) { + const baseDir = resolveDirectoryBaseDir() + await fsp.mkdir(baseDir, { recursive: true }) + const shouldUseStepDirectories = entries.length > 1 + + for (const { stepName, file } of allFiles) { + const resultUrl = getFileUrl(file) + if (!resultUrl) continue + + const targetDir = shouldUseStepDirectories ? path.join(baseDir, stepName) : baseDir + await fsp.mkdir(targetDir, { recursive: true }) + + const rawName = + file.name ?? + (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? + `${stepName}_result` + const safeName = sanitizeName(rawName) + const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) + + await downloadResultFile(resultUrl, targetPath) + } + } else if (allFiles.length === 1) { + const first = allFiles[0] + const resultUrl = first ? getFileUrl(first.file) : null + if (resultUrl) { + await downloadResultFile(resultUrl, outPath) + } + } else { + const legacyBaseDir = path.join(path.dirname(outPath), path.parse(outPath).name) + + for (const { stepName, file } of allFiles) { + const resultUrl = getFileUrl(file) + if (!resultUrl) continue + + const targetDir = path.join(legacyBaseDir, stepName) + await fsp.mkdir(targetDir, { recursive: true }) + + const rawName = + file.name ?? + (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? + `${stepName}_result` + const safeName = sanitizeName(rawName) + const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) + + await downloadResultFile(resultUrl, targetPath) } } } else if (outPath != null) { const first = allFiles[0] const resultUrl = first ? getFileUrl(first.file) : null if (resultUrl) { - outputctl.debug('DOWNLOADING') - const [dlErr] = await tryCatch( - downloadResultToFile(resultUrl, outPath, abortController.signal), - ) - if (dlErr) { - if (dlErr.name !== 'AbortError') { - outputctl.error(dlErr.message) - throw dlErr - } - } + await downloadResultFile(resultUrl, outPath) } } } diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index 373e9f3f..9e496cac 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -850,7 +850,7 @@ export class ImageResizeCommand extends AuthenticatedCommand { { name: 'colorspace', kind: 'string' }, { name: 'type', kind: 'string' }, { name: 'sepia', kind: 'number' }, - { name: 'rotation', kind: 'string' }, + { name: 'rotation', kind: 'auto' }, { name: 'compress', kind: 'string' }, { name: 'blur', kind: 'string' }, { name: 'brightness', kind: 'number' }, @@ -868,11 +868,11 @@ export class ImageResizeCommand extends AuthenticatedCommand { { name: 'progressive', kind: 'boolean' }, { name: 'transparent', kind: 'string' }, { name: 'trim_whitespace', kind: 'boolean' }, - { name: 'clip', kind: 'string' }, + { name: 'clip', kind: 'auto' }, { name: 'negate', kind: 'boolean' }, { name: 'density', kind: 'string' }, { name: 'monochrome', kind: 'boolean' }, - { name: 'shave', kind: 'string' }, + { name: 'shave', kind: 'auto' }, ], rawValues: { format: this.format, @@ -946,7 +946,10 @@ export class DocumentConvertCommand extends AuthenticatedCommand { description: 'Convert documents into different formats', details: 'Runs `/document/convert` on each input file and writes the result to `--out`.', examples: [ - ['Run the command', 'transloadit document convert --input input.pdf --out output.pdf'], + [ + 'Run the command', + 'transloadit document convert --input input.pdf --format pdf --out output.pdf', + ], ], }) @@ -1682,7 +1685,7 @@ export class AudioWaveformCommand extends AuthenticatedCommand { { name: 'format', kind: 'string' }, { name: 'width', kind: 'number' }, { name: 'height', kind: 'number' }, - { name: 'antialiasing', kind: 'string' }, + { name: 'antialiasing', kind: 'auto' }, { name: 'background_color', kind: 'string' }, { name: 'center_color', kind: 'string' }, { name: 'outer_color', kind: 'string' }, @@ -1759,16 +1762,18 @@ export class TextSpeakCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', description: 'Speak text', - details: 'Runs `/text/speak` and writes the result to `--out`.', + details: 'Runs `/text/speak` on each input file and writes the result to `--out`.', examples: [ - ['Run the command', 'transloadit text speak --prompt "Hello world" --out output.mp3'], + [ + 'Run the command', + 'transloadit text speak --input input.pdf --provider aws --out output.mp3', + ], ], }) prompt = Option.String('--prompt', { description: 'Which text to speak. You can also set this to `null` and supply an input text file.', - required: true, }) provider = Option.String('--provider', { @@ -1792,18 +1797,66 @@ export class TextSpeakCommand extends AuthenticatedCommand { 'Supply [Speech Synthesis Markup Language](https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language) instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations.\n\nPlease see the supported syntaxes for [AWS](https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html) and [GCP](https://cloud.google.com/text-to-speech/docs/ssml).', }) + inputs = Option.Array('--input,-i', { + description: 'Provide an input file or a directory', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + outputPath = Option.String('--out,-o', { - description: 'Write the result to this path', + description: 'Write the result to this path or directory', required: true, }) protected async run(): Promise { + if ((this.inputs ?? []).length === 0 && this.prompt == null) { + this.output.error('text speak requires --input or --prompt') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + const step = parseIntentStep({ schema: robotTextSpeakInstructionsSchema, - fixedValues: { - robot: '/text/speak', - result: true, - }, + fixedValues: + (this.inputs ?? []).length > 0 + ? { + ...{ + robot: '/text/speak', + result: true, + }, + use: ':original', + } + : { + robot: '/text/speak', + result: true, + }, fieldSpecs: [ { name: 'prompt', kind: 'string' }, { name: 'provider', kind: 'string' }, @@ -1824,8 +1877,15 @@ export class TextSpeakCommand extends AuthenticatedCommand { stepsData: { synthesized: step, }, - inputs: [], + inputs: this.inputs ?? [], output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), }) return hasFailures ? 1 : undefined diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 6a1fc9e1..2ac92210 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -1,6 +1,6 @@ import type { z } from 'zod' -export type IntentFieldKind = 'boolean' | 'number' | 'string' +export type IntentFieldKind = 'auto' | 'boolean' | 'number' | 'string' export interface IntentFieldSpec { kind: IntentFieldKind @@ -10,7 +10,34 @@ export interface IntentFieldSpec { export function coerceIntentFieldValue( kind: IntentFieldKind, raw: string, + fieldSchema?: z.ZodTypeAny, ): boolean | number | string { + if (kind === 'auto') { + if (fieldSchema == null) { + return raw + } + + const candidates: unknown[] = [raw] + + if (raw === 'true' || raw === 'false') { + candidates.push(raw === 'true') + } + + const numericValue = Number(raw) + if (raw.trim() !== '' && !Number.isNaN(numericValue)) { + candidates.push(numericValue) + } + + for (const candidate of candidates) { + const parsed = fieldSchema.safeParse(candidate) + if (parsed.success) { + return parsed.data as boolean | number | string + } + } + + return raw + } + if (kind === 'number') { const value = Number(raw) if (Number.isNaN(value)) { @@ -44,7 +71,8 @@ export function parseIntentStep({ for (const fieldSpec of fieldSpecs) { const rawValue = rawValues[fieldSpec.name] if (rawValue == null) continue - input[fieldSpec.name] = coerceIntentFieldValue(fieldSpec.kind, rawValue) + const fieldSchema = schema.shape[fieldSpec.name] + input[fieldSpec.name] = coerceIntentFieldValue(fieldSpec.kind, rawValue, fieldSchema) } const parsed = schema.parse(input) as Record diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 40626c12..26d340b0 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import nock from 'nock' @@ -15,6 +15,27 @@ async function createTempDir(prefix: string): Promise { return tempDir } +function getLegacyRelativeInputPath(inputPath: string): string { + return path.relative(process.cwd(), inputPath).replace(/^(\.\.\/)+/, '') +} + +async function collectRelativeFiles(rootDir: string, currentDir = rootDir): Promise { + const entries = await readdir(currentDir, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name) + if (entry.isDirectory()) { + files.push(...(await collectRelativeFiles(rootDir, fullPath))) + continue + } + + files.push(path.relative(rootDir, fullPath)) + } + + return files.sort() +} + afterEach(async () => { vi.restoreAllMocks() nock.cleanAll() @@ -76,6 +97,60 @@ describe('assemblies create', () => { expect(await readFile(outputPath, 'utf8')).toBe('bundle-contents') }) + it('keeps unchanged inputs in single-assembly rebuilds when one input is stale', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-bundle-stale-') + const inputA = path.join(tempDir, 'a.txt') + const inputB = path.join(tempDir, 'b.txt') + const outputPath = path.join(tempDir, 'bundle.zip') + + await writeFile(inputA, 'a') + await writeFile(inputB, 'b') + await writeFile(outputPath, 'old-bundle') + + const baseTime = new Date('2026-01-01T00:00:00.000Z') + const outputTime = new Date('2026-01-01T00:00:10.000Z') + const changedInputTime = new Date('2026-01-01T00:00:20.000Z') + + await utimes(inputA, changedInputTime, changedInputTime) + await utimes(inputB, baseTime, baseTime) + await utimes(outputPath, outputTime, outputTime) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-stale-bundle' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + compressed: [{ url: 'http://downloads.test/bundle.zip', name: 'bundle.zip' }], + }, + }), + } + + nock('http://downloads.test').get('/bundle.zip').reply(200, 'bundle-contents') + + await create(output, client as never, { + inputs: [inputA, inputB], + output: outputPath, + singleAssembly: true, + stepsData: { + compressed: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + }, + }) + + expect(client.createAssembly).toHaveBeenCalledTimes(1) + const uploads = client.createAssembly.mock.calls[0]?.[0]?.uploads + expect(Object.keys(uploads ?? {})).toEqual(['a.txt', 'b.txt']) + }) + it('writes single-input directory outputs using result filenames', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -84,6 +159,7 @@ describe('assemblies create', () => { const outputDir = path.join(tempDir, 'thumbs') await writeFile(inputPath, 'video') + await mkdir(outputDir, { recursive: true }) const output = new OutputCtl() const client = { @@ -129,6 +205,58 @@ describe('assemblies create', () => { expect(await readFile(path.join(outputDir, 'two.jpg'), 'utf8')).toBe('two') }) + it('preserves legacy step-directory layout for generic directory outputs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-legacy-outdir-') + const inputPath = path.join(tempDir, 'clip.mp4') + const outputDir = path.join(tempDir, 'thumbs') + + await writeFile(inputPath, 'video') + await mkdir(outputDir, { recursive: true }) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-legacy-dir' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + thumbs: [ + { url: 'http://downloads.test/one.jpg', name: 'one.jpg' }, + { url: 'http://downloads.test/two.jpg', name: 'two.jpg' }, + ], + }, + }), + } + + nock('http://downloads.test').get('/one.jpg').reply(200, 'one') + nock('http://downloads.test').get('/two.jpg').reply(200, 'two') + + await create( + output, + client as never, + { + inputs: [inputPath], + output: outputDir, + stepsData: { + thumbs: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + }, + } as never, + ) + + const legacyRelative = getLegacyRelativeInputPath(inputPath) + const legacyBaseDir = path.join(path.dirname(legacyRelative), path.parse(legacyRelative).name) + + expect(await collectRelativeFiles(outputDir)).toEqual([ + path.join(legacyBaseDir, 'thumbs', 'one.jpg'), + path.join(legacyBaseDir, 'thumbs', 'two.jpg'), + ]) + }) + it('uses the actual result filename for single-result directory outputs', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -137,6 +265,7 @@ describe('assemblies create', () => { const outputDir = path.join(tempDir, 'extracted') await writeFile(inputPath, 'zip-data') + await mkdir(outputDir, { recursive: true }) const output = new OutputCtl() const client = { @@ -173,6 +302,44 @@ describe('assemblies create', () => { expect(await readFile(path.join(outputDir, 'input.txt'), 'utf8')).toBe('hello') }) + it('preserves mapped out paths for legacy single-result directory outputs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-legacy-single-result-') + const inputPath = path.join(tempDir, 'archive.zip') + const outputDir = path.join(tempDir, 'extracted') + + await writeFile(inputPath, 'zip-data') + await mkdir(outputDir, { recursive: true }) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-legacy-single-result' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + decompressed: [{ url: 'http://downloads.test/input.txt', name: 'input.txt' }], + }, + }), + } + + nock('http://downloads.test').get('/input.txt').reply(200, 'hello') + + await create(output, client as never, { + inputs: [inputPath], + output: outputDir, + stepsData: { + decompressed: { + robot: '/file/decompress', + result: true, + use: ':original', + }, + }, + }) + + expect(await collectRelativeFiles(outputDir)).toEqual([getLegacyRelativeInputPath(inputPath)]) + }) + it('does not create an empty output file when assembly creation fails', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 312583d2..f0ff99a1 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as assembliesCommands from '../../../src/cli/commands/assemblies.ts' +import { + DocumentConvertCommand, + TextSpeakCommand, +} from '../../../src/cli/commands/generated-intents.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' @@ -185,6 +189,88 @@ describe('intent commands', () => { ) }) + it('supports prompt-only text speak runs without an input file', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'text', + 'speak', + '--prompt', + 'Hello from a prompt', + '--provider', + 'aws', + '--out', + 'hello.mp3', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [], + output: 'hello.mp3', + stepsData: { + synthesized: { + robot: '/text/speak', + result: true, + prompt: 'Hello from a prompt', + provider: 'aws', + }, + }, + }), + ) + }) + + it('supports file-backed text speak runs without a prompt', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'text', + 'speak', + '--input', + 'article.txt', + '--provider', + 'aws', + '--out', + 'hello.mp3', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['article.txt'], + output: 'hello.mp3', + stepsData: { + synthesized: { + robot: '/text/speak', + result: true, + use: ':original', + provider: 'aws', + }, + }, + }), + ) + }) + it('omits schema defaults from generated intent steps', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -310,6 +396,80 @@ describe('intent commands', () => { ) }) + it('coerces mixed rotation flags like image resize --rotation 90', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'resize', + '--input', + 'demo.jpg', + '--rotation', + '90', + '--out', + 'resized.jpg', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + resized: expect.objectContaining({ + robot: '/image/resize', + rotation: 90, + }), + }, + }), + ) + }) + + it('coerces mixed boolean-or-number flags like audio waveform --antialiasing 1', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'audio', + 'waveform', + '--input', + 'song.mp3', + '--antialiasing', + '1', + '--out', + 'waveform.png', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + waveformed: expect.objectContaining({ + robot: '/audio/waveform', + antialiasing: 1, + }), + }, + }), + ) + }) + it('maps file compress to a bundled single assembly by default', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -419,4 +579,13 @@ describe('intent commands', () => { }), ) }) + + it('includes required schema flags in generated usage examples', () => { + expect(DocumentConvertCommand.usage.examples).toEqual([ + ['Run the command', expect.stringContaining('--format')], + ]) + expect(TextSpeakCommand.usage.examples).toEqual([ + ['Run the command', expect.stringContaining('--provider')], + ]) + }) }) From afdab146269c72ca275ab6ed65ea3bc210d82160 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 26 Mar 2026 13:15:47 +0100 Subject: [PATCH 09/44] refactor(node-cli): remove dead assembly supersession path --- packages/node/src/cli/commands/assemblies.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 59a910aa..39d59af0 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1005,7 +1005,6 @@ export async function create( async function processAssemblyJob( inPath: string | null, outPath: string | null, - _outMtime: Date | undefined, ): Promise { outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`) @@ -1013,8 +1012,6 @@ export async function create( const inStream = inPath ? fs.createReadStream(inPath) : null inStream?.on('error', () => {}) - const superceded = false - const createOptions: CreateAssemblyOptions = { params, signal: abortController.signal, @@ -1024,24 +1021,18 @@ export async function create( } const result = await client.createAssembly(createOptions) - if (superceded) return undefined const assemblyId = result.assembly_id if (!assemblyId) throw new Error('No assembly_id in result') const assembly = await client.awaitAssemblyCompletion(assemblyId, { signal: abortController.signal, - onPoll: () => { - if (superceded) return false - return true - }, + onPoll: () => true, onAssemblyProgress: (status) => { outputctl.debug(`Assembly status: ${status.ok}`) }, }) - if (superceded) return undefined - if (assembly.error || (assembly.ok && assembly.ok !== 'ASSEMBLY_COMPLETED')) { const msg = `Assembly failed: ${assembly.error || assembly.message} (Status: ${assembly.ok})` outputctl.error(msg) @@ -1118,7 +1109,7 @@ export async function create( } } - if (resolvedOutput != null && !superceded) { + if (resolvedOutput != null) { if (outIsDirectory) { if (useIntentDirectoryLayout || outPath == null) { const baseDir = resolveDirectoryBaseDir() @@ -1308,7 +1299,6 @@ export async function create( ? (((job.in as fs.ReadStream).path as string | undefined) ?? null) : null const outPath = job.out?.path ?? null - const outMtime = job.out?.mtime outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`) // Close the original streams immediately - we'll create fresh ones when processing @@ -1322,7 +1312,7 @@ export async function create( // Add job to queue - p-queue handles concurrency automatically queue .add(async () => { - const result = await processAssemblyJob(inPath, outPath, outMtime) + const result = await processAssemblyJob(inPath, outPath) if (result !== undefined) { results.push(result) } From 6dc62609ebbe904818f02e218840fe888053b4a0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 26 Mar 2026 13:54:13 +0100 Subject: [PATCH 10/44] feat(node-cli): support generic intent inputs --- packages/node/README.md | 5 +- .../node/scripts/generate-intent-commands.ts | 51 +- .../src/cli/commands/generated-intents.ts | 1668 ++++++++++------- packages/node/src/cli/intentCommandSpecs.ts | 38 +- packages/node/src/cli/intentRuntime.ts | 117 ++ packages/node/test/unit/cli/intents.test.ts | 90 +- 6 files changed, 1233 insertions(+), 736 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 8d84defb..d84c3443 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -90,9 +90,12 @@ For common one-off tasks, prefer the intent-first commands: # Generate an image from a text prompt npx transloadit image generate --prompt "A red bicycle in a studio" --out bicycle.png -# Generate a preview for a remote file URL +# Generate a preview for any input path or URL npx transloadit preview generate --input https://example.com/file.pdf --out preview.png +# Paste base64 input directly into an intent command +npx transloadit document convert --input-base64 "$(base64 -i input.txt)" --format pdf --out output.pdf + # Encode a video into an HLS package npx transloadit video encode-hls --input input.mp4 --out dist/hls ``` diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 8c6edc55..733221a2 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -317,7 +317,7 @@ function inferLocalFilesInput({ if (defaultSingleAssembly) { return { kind: 'local-files', - description: 'Provide one or more input files or directories', + description: 'Provide one or more input paths, directories, URLs, or - for stdin', recursive: true, deleteAfterProcessing: true, reprocessStale: true, @@ -328,7 +328,7 @@ function inferLocalFilesInput({ return { kind: 'local-files', - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', recursive: true, allowWatch: true, deleteAfterProcessing: true, @@ -558,7 +558,7 @@ function resolveRobotIntentSpec(entry: RobotIntentCatalogEntry): ResolvedIntentC throw new Error(`No robot intent definition found for "${entry.robot}"`) } - const paths = inferCommandPathsFromRobot(definition.robot) + const paths = entry.paths ?? inferCommandPathsFromRobot(definition.robot) const inputMode = inferInputMode(entry, definition) const outputMode = inferOutputMode(entry) const input = inferInputSpec(entry, definition) @@ -711,6 +711,9 @@ function formatLocalInputOptions(input: ResolvedIntentLocalFilesInput): string { const blocks = [ ` inputs = Option.Array('--input,-i', { description: ${JSON.stringify(input.description)}, + })`, + ` inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', })`, ] @@ -774,7 +777,7 @@ function formatLocalCreateOptions(spec: ResolvedIntentCommandSpec): string { throw new Error('Expected a local-files input spec') } - const entries = [' inputs: this.inputs ?? [],', ' output: this.outputPath,'] + const entries = [' inputs: preparedInputs.inputs,', ' output: this.outputPath,'] if (spec.outputMode != null) { entries.push(` outputMode: ${JSON.stringify(spec.outputMode)},`) @@ -819,13 +822,13 @@ function formatLocalValidation(spec: ResolvedIntentCommandSpec, commandLabel: st const lines = spec.input.requiredFieldForInputless == null ? [ - ' if ((this.inputs ?? []).length === 0) {', - ` this.output.error('${commandLabel} requires at least one --input')`, + ' if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) {', + ` this.output.error('${commandLabel} requires --input or --input-base64')`, ' return 1', ' }', ] : [ - ` if ((this.inputs ?? []).length === 0 && this.${toCamelCase(spec.input.requiredFieldForInputless)} == null) {`, + ` if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0 && this.${toCamelCase(spec.input.requiredFieldForInputless)} == null) {`, ` this.output.error('${commandLabel} requires --input or --${toKebabCase(spec.input.requiredFieldForInputless)}')`, ' return 1', ' }', @@ -881,6 +884,16 @@ function formatRunBody( fieldSpecs: GeneratedSchemaField[], ): string { const schemaSpec = spec.schemaSpec + const transientWatchGuard = + spec.input.kind === 'local-files' && spec.input.allowWatch + ? ` + + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + }` + : '' + if (spec.execution.kind === 'single-step') { const parseStep = ` const step = parseIntentStep({ schema: ${schemaSpec?.importName}, @@ -892,6 +905,12 @@ function formatRunBody( if (spec.input.kind === 'local-files') { return `${formatLocalValidation(spec, spec.paths.join(' '))} + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], + })${transientWatchGuard} + + try { ${parseStep} const { hasFailures } = await assembliesCommands.create(this.output, this.client, { @@ -901,7 +920,10 @@ ${parseStep} ${formatLocalCreateOptions(spec)} }) - return hasFailures ? 1 : undefined` + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + }` } return `${parseStep} @@ -951,12 +973,21 @@ ${formatLocalCreateOptions(spec)} return `${formatLocalValidation(spec, spec.paths.join(' '))} + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], + })${transientWatchGuard} + + try { const { hasFailures } = await assembliesCommands.create(this.output, this.client, { template: ${JSON.stringify(spec.execution.templateId)}, ${formatLocalCreateOptions(spec)} }) - return hasFailures ? 1 : undefined` + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + }` } function resolveFixedValues(spec: ResolvedIntentCommandSpec): Record { @@ -1031,7 +1062,7 @@ import { Command, Option } from 'clipanion' import * as t from 'typanion' ${generateImports(specs)} -import { parseIntentStep } from '../intentRuntime.ts' +import { parseIntentStep, prepareIntentInputs } from '../intentRuntime.ts' import * as assembliesCommands from './assemblies.ts' import { AuthenticatedCommand } from './BaseCommand.ts' ${commandClasses.join('\n')} diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index 9e496cac..e18e6bcb 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -18,7 +18,7 @@ import { robotImageOptimizeInstructionsSchema } from '../../alphalib/types/robot import { robotImageResizeInstructionsSchema } from '../../alphalib/types/robots/image-resize.ts' import { robotTextSpeakInstructionsSchema } from '../../alphalib/types/robots/text-speak.ts' import { robotVideoThumbsInstructionsSchema } from '../../alphalib/types/robots/video-thumbs.ts' -import { parseIntentStep } from '../intentRuntime.ts' +import { parseIntentStep, prepareIntentInputs } from '../intentRuntime.ts' import * as assembliesCommands from './assemblies.ts' import { AuthenticatedCommand } from './BaseCommand.ts' @@ -127,14 +127,10 @@ export class PreviewGenerateCommand extends AuthenticatedCommand { static override usage = Command.Usage({ category: 'Intent Commands', - description: 'Generate a preview image for a remote file URL', - details: - 'Imports a remote file with `/http/import`, then runs `/file/preview` and downloads the preview to `--out`.', + description: 'Generate a preview thumbnail', + details: 'Runs `/file/preview` on each input file and writes the result to `--out`.', examples: [ - [ - 'Preview a remote PDF', - 'transloadit preview generate --input https://example.com/file.pdf --width 300 --height 200 --out preview.png', - ], + ['Run the command', 'transloadit preview generate --input input.file --out output.file'], ], }) @@ -249,91 +245,144 @@ export class PreviewGenerateCommand extends AuthenticatedCommand { 'Specifies whether the generated animated image should loop forever (`true`) or stop after playing the animation once (`false`). Only used if the `clip` strategy for video files is applied.', }) - input = Option.String('--input,-i', { - description: 'Remote URL to preview', - required: true, + inputs = Option.Array('--input,-i', { + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), }) outputPath = Option.String('--out,-o', { - description: 'Write the generated preview image to this path', + description: 'Write the result to this path or directory', required: true, }) protected async run(): Promise { - const previewStep = parseIntentStep({ - schema: robotFilePreviewInstructionsSchema, - fixedValues: { - robot: '/file/preview', - result: true, - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'artwork_outer_color', kind: 'string' }, - { name: 'artwork_center_color', kind: 'string' }, - { name: 'waveform_center_color', kind: 'string' }, - { name: 'waveform_outer_color', kind: 'string' }, - { name: 'waveform_height', kind: 'number' }, - { name: 'waveform_width', kind: 'number' }, - { name: 'icon_style', kind: 'string' }, - { name: 'icon_text_color', kind: 'string' }, - { name: 'icon_text_font', kind: 'string' }, - { name: 'icon_text_content', kind: 'string' }, - { name: 'optimize', kind: 'boolean' }, - { name: 'optimize_priority', kind: 'string' }, - { name: 'optimize_progressive', kind: 'boolean' }, - { name: 'clip_format', kind: 'string' }, - { name: 'clip_offset', kind: 'number' }, - { name: 'clip_duration', kind: 'number' }, - { name: 'clip_framerate', kind: 'number' }, - { name: 'clip_loop', kind: 'boolean' }, - ], - rawValues: { - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - artwork_outer_color: this.artworkOuterColor, - artwork_center_color: this.artworkCenterColor, - waveform_center_color: this.waveformCenterColor, - waveform_outer_color: this.waveformOuterColor, - waveform_height: this.waveformHeight, - waveform_width: this.waveformWidth, - icon_style: this.iconStyle, - icon_text_color: this.iconTextColor, - icon_text_font: this.iconTextFont, - icon_text_content: this.iconTextContent, - optimize: this.optimize, - optimize_priority: this.optimizePriority, - optimize_progressive: this.optimizeProgressive, - clip_format: this.clipFormat, - clip_offset: this.clipOffset, - clip_duration: this.clipDuration, - clip_framerate: this.clipFramerate, - clip_loop: this.clipLoop, - }, + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('preview generate requires --input or --input-base64') + return 1 + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - imported: { - robot: '/http/import', - url: this.input, + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } + + try { + const step = parseIntentStep({ + schema: robotFilePreviewInstructionsSchema, + fixedValues: { + robot: '/file/preview', + result: true, + use: ':original', }, - preview: { - ...previewStep, - use: 'imported', + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'artwork_outer_color', kind: 'string' }, + { name: 'artwork_center_color', kind: 'string' }, + { name: 'waveform_center_color', kind: 'string' }, + { name: 'waveform_outer_color', kind: 'string' }, + { name: 'waveform_height', kind: 'number' }, + { name: 'waveform_width', kind: 'number' }, + { name: 'icon_style', kind: 'string' }, + { name: 'icon_text_color', kind: 'string' }, + { name: 'icon_text_font', kind: 'string' }, + { name: 'icon_text_content', kind: 'string' }, + { name: 'optimize', kind: 'boolean' }, + { name: 'optimize_priority', kind: 'string' }, + { name: 'optimize_progressive', kind: 'boolean' }, + { name: 'clip_format', kind: 'string' }, + { name: 'clip_offset', kind: 'number' }, + { name: 'clip_duration', kind: 'number' }, + { name: 'clip_framerate', kind: 'number' }, + { name: 'clip_loop', kind: 'boolean' }, + ], + rawValues: { + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + artwork_outer_color: this.artworkOuterColor, + artwork_center_color: this.artworkCenterColor, + waveform_center_color: this.waveformCenterColor, + waveform_outer_color: this.waveformOuterColor, + waveform_height: this.waveformHeight, + waveform_width: this.waveformWidth, + icon_style: this.iconStyle, + icon_text_color: this.iconTextColor, + icon_text_font: this.iconTextFont, + icon_text_content: this.iconTextContent, + optimize: this.optimize, + optimize_priority: this.optimizePriority, + optimize_progressive: this.optimizeProgressive, + clip_format: this.clipFormat, + clip_offset: this.clipOffset, + clip_duration: this.clipDuration, + clip_framerate: this.clipFramerate, + clip_loop: this.clipLoop, }, - }, - inputs: [], - output: this.outputPath, - }) + }) - return hasFailures ? 1 : undefined + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + preview: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -367,7 +416,11 @@ export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -401,8 +454,8 @@ export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('image remove-background requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('image remove-background requires --input or --input-base64') return 1 } @@ -411,43 +464,57 @@ export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotImageBgremoveInstructionsSchema, - fixedValues: { - robot: '/image/bgremove', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'select', kind: 'string' }, - { name: 'format', kind: 'string' }, - { name: 'provider', kind: 'string' }, - { name: 'model', kind: 'string' }, - ], - rawValues: { - select: this.select, - format: this.format, - provider: this.provider, - model: this.model, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - removed_background: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotImageBgremoveInstructionsSchema, + fixedValues: { + robot: '/image/bgremove', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'select', kind: 'string' }, + { name: 'format', kind: 'string' }, + { name: 'provider', kind: 'string' }, + { name: 'model', kind: 'string' }, + ], + rawValues: { + select: this.select, + format: this.format, + provider: this.provider, + model: this.model, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + removed_background: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -484,7 +551,11 @@ export class ImageOptimizeCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -518,8 +589,8 @@ export class ImageOptimizeCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('image optimize requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('image optimize requires --input or --input-base64') return 1 } @@ -528,43 +599,57 @@ export class ImageOptimizeCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotImageOptimizeInstructionsSchema, - fixedValues: { - robot: '/image/optimize', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'priority', kind: 'string' }, - { name: 'progressive', kind: 'boolean' }, - { name: 'preserve_meta_data', kind: 'boolean' }, - { name: 'fix_breaking_images', kind: 'boolean' }, - ], - rawValues: { - priority: this.priority, - progressive: this.progressive, - preserve_meta_data: this.preserveMetaData, - fix_breaking_images: this.fixBreakingImages, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - optimized: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotImageOptimizeInstructionsSchema, + fixedValues: { + robot: '/image/optimize', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'priority', kind: 'string' }, + { name: 'progressive', kind: 'boolean' }, + { name: 'preserve_meta_data', kind: 'boolean' }, + { name: 'fix_breaking_images', kind: 'boolean' }, + ], + rawValues: { + priority: this.priority, + progressive: this.progressive, + preserve_meta_data: this.preserveMetaData, + fix_breaking_images: this.fixBreakingImages, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + optimized: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -780,7 +865,11 @@ export class ImageResizeCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -814,8 +903,8 @@ export class ImageResizeCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('image resize requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('image resize requires --input or --input-base64') return 1 } @@ -824,117 +913,131 @@ export class ImageResizeCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotImageResizeInstructionsSchema, - fixedValues: { - robot: '/image/resize', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'zoom', kind: 'boolean' }, - { name: 'gravity', kind: 'string' }, - { name: 'strip', kind: 'boolean' }, - { name: 'alpha', kind: 'string' }, - { name: 'preclip_alpha', kind: 'string' }, - { name: 'flatten', kind: 'boolean' }, - { name: 'correct_gamma', kind: 'boolean' }, - { name: 'quality', kind: 'number' }, - { name: 'adaptive_filtering', kind: 'boolean' }, - { name: 'background', kind: 'string' }, - { name: 'frame', kind: 'number' }, - { name: 'colorspace', kind: 'string' }, - { name: 'type', kind: 'string' }, - { name: 'sepia', kind: 'number' }, - { name: 'rotation', kind: 'auto' }, - { name: 'compress', kind: 'string' }, - { name: 'blur', kind: 'string' }, - { name: 'brightness', kind: 'number' }, - { name: 'saturation', kind: 'number' }, - { name: 'hue', kind: 'number' }, - { name: 'contrast', kind: 'number' }, - { name: 'watermark_url', kind: 'string' }, - { name: 'watermark_x_offset', kind: 'number' }, - { name: 'watermark_y_offset', kind: 'number' }, - { name: 'watermark_size', kind: 'string' }, - { name: 'watermark_resize_strategy', kind: 'string' }, - { name: 'watermark_opacity', kind: 'number' }, - { name: 'watermark_repeat_x', kind: 'boolean' }, - { name: 'watermark_repeat_y', kind: 'boolean' }, - { name: 'progressive', kind: 'boolean' }, - { name: 'transparent', kind: 'string' }, - { name: 'trim_whitespace', kind: 'boolean' }, - { name: 'clip', kind: 'auto' }, - { name: 'negate', kind: 'boolean' }, - { name: 'density', kind: 'string' }, - { name: 'monochrome', kind: 'boolean' }, - { name: 'shave', kind: 'auto' }, - ], - rawValues: { - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - zoom: this.zoom, - gravity: this.gravity, - strip: this.strip, - alpha: this.alpha, - preclip_alpha: this.preclipAlpha, - flatten: this.flatten, - correct_gamma: this.correctGamma, - quality: this.quality, - adaptive_filtering: this.adaptiveFiltering, - background: this.background, - frame: this.frame, - colorspace: this.colorspace, - type: this.type, - sepia: this.sepia, - rotation: this.rotation, - compress: this.compress, - blur: this.blur, - brightness: this.brightness, - saturation: this.saturation, - hue: this.hue, - contrast: this.contrast, - watermark_url: this.watermarkUrl, - watermark_x_offset: this.watermarkXOffset, - watermark_y_offset: this.watermarkYOffset, - watermark_size: this.watermarkSize, - watermark_resize_strategy: this.watermarkResizeStrategy, - watermark_opacity: this.watermarkOpacity, - watermark_repeat_x: this.watermarkRepeatX, - watermark_repeat_y: this.watermarkRepeatY, - progressive: this.progressive, - transparent: this.transparent, - trim_whitespace: this.trimWhitespace, - clip: this.clip, - negate: this.negate, - density: this.density, - monochrome: this.monochrome, - shave: this.shave, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - resized: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotImageResizeInstructionsSchema, + fixedValues: { + robot: '/image/resize', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'zoom', kind: 'boolean' }, + { name: 'gravity', kind: 'string' }, + { name: 'strip', kind: 'boolean' }, + { name: 'alpha', kind: 'string' }, + { name: 'preclip_alpha', kind: 'string' }, + { name: 'flatten', kind: 'boolean' }, + { name: 'correct_gamma', kind: 'boolean' }, + { name: 'quality', kind: 'number' }, + { name: 'adaptive_filtering', kind: 'boolean' }, + { name: 'background', kind: 'string' }, + { name: 'frame', kind: 'number' }, + { name: 'colorspace', kind: 'string' }, + { name: 'type', kind: 'string' }, + { name: 'sepia', kind: 'number' }, + { name: 'rotation', kind: 'auto' }, + { name: 'compress', kind: 'string' }, + { name: 'blur', kind: 'string' }, + { name: 'brightness', kind: 'number' }, + { name: 'saturation', kind: 'number' }, + { name: 'hue', kind: 'number' }, + { name: 'contrast', kind: 'number' }, + { name: 'watermark_url', kind: 'string' }, + { name: 'watermark_x_offset', kind: 'number' }, + { name: 'watermark_y_offset', kind: 'number' }, + { name: 'watermark_size', kind: 'string' }, + { name: 'watermark_resize_strategy', kind: 'string' }, + { name: 'watermark_opacity', kind: 'number' }, + { name: 'watermark_repeat_x', kind: 'boolean' }, + { name: 'watermark_repeat_y', kind: 'boolean' }, + { name: 'progressive', kind: 'boolean' }, + { name: 'transparent', kind: 'string' }, + { name: 'trim_whitespace', kind: 'boolean' }, + { name: 'clip', kind: 'auto' }, + { name: 'negate', kind: 'boolean' }, + { name: 'density', kind: 'string' }, + { name: 'monochrome', kind: 'boolean' }, + { name: 'shave', kind: 'auto' }, + ], + rawValues: { + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + zoom: this.zoom, + gravity: this.gravity, + strip: this.strip, + alpha: this.alpha, + preclip_alpha: this.preclipAlpha, + flatten: this.flatten, + correct_gamma: this.correctGamma, + quality: this.quality, + adaptive_filtering: this.adaptiveFiltering, + background: this.background, + frame: this.frame, + colorspace: this.colorspace, + type: this.type, + sepia: this.sepia, + rotation: this.rotation, + compress: this.compress, + blur: this.blur, + brightness: this.brightness, + saturation: this.saturation, + hue: this.hue, + contrast: this.contrast, + watermark_url: this.watermarkUrl, + watermark_x_offset: this.watermarkXOffset, + watermark_y_offset: this.watermarkYOffset, + watermark_size: this.watermarkSize, + watermark_resize_strategy: this.watermarkResizeStrategy, + watermark_opacity: this.watermarkOpacity, + watermark_repeat_x: this.watermarkRepeatX, + watermark_repeat_y: this.watermarkRepeatY, + progressive: this.progressive, + transparent: this.transparent, + trim_whitespace: this.trimWhitespace, + clip: this.clip, + negate: this.negate, + density: this.density, + monochrome: this.monochrome, + shave: this.shave, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + resized: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -999,7 +1102,11 @@ export class DocumentConvertCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -1033,8 +1140,8 @@ export class DocumentConvertCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('document convert requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('document convert requires --input or --input-base64') return 1 } @@ -1043,53 +1150,67 @@ export class DocumentConvertCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotDocumentConvertInstructionsSchema, - fixedValues: { - robot: '/document/convert', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'markdown_format', kind: 'string' }, - { name: 'markdown_theme', kind: 'string' }, - { name: 'pdf_margin', kind: 'string' }, - { name: 'pdf_print_background', kind: 'boolean' }, - { name: 'pdf_format', kind: 'string' }, - { name: 'pdf_display_header_footer', kind: 'boolean' }, - { name: 'pdf_header_template', kind: 'string' }, - { name: 'pdf_footer_template', kind: 'string' }, - ], - rawValues: { - format: this.format, - markdown_format: this.markdownFormat, - markdown_theme: this.markdownTheme, - pdf_margin: this.pdfMargin, - pdf_print_background: this.pdfPrintBackground, - pdf_format: this.pdfFormat, - pdf_display_header_footer: this.pdfDisplayHeaderFooter, - pdf_header_template: this.pdfHeaderTemplate, - pdf_footer_template: this.pdfFooterTemplate, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - converted: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotDocumentConvertInstructionsSchema, + fixedValues: { + robot: '/document/convert', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'markdown_format', kind: 'string' }, + { name: 'markdown_theme', kind: 'string' }, + { name: 'pdf_margin', kind: 'string' }, + { name: 'pdf_print_background', kind: 'boolean' }, + { name: 'pdf_format', kind: 'string' }, + { name: 'pdf_display_header_footer', kind: 'boolean' }, + { name: 'pdf_header_template', kind: 'string' }, + { name: 'pdf_footer_template', kind: 'string' }, + ], + rawValues: { + format: this.format, + markdown_format: this.markdownFormat, + markdown_theme: this.markdownTheme, + pdf_margin: this.pdfMargin, + pdf_print_background: this.pdfPrintBackground, + pdf_format: this.pdfFormat, + pdf_display_header_footer: this.pdfDisplayHeaderFooter, + pdf_header_template: this.pdfHeaderTemplate, + pdf_footer_template: this.pdfFooterTemplate, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + converted: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -1141,7 +1262,11 @@ export class DocumentOptimizeCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -1175,8 +1300,8 @@ export class DocumentOptimizeCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('document optimize requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('document optimize requires --input or --input-base64') return 1 } @@ -1185,49 +1310,63 @@ export class DocumentOptimizeCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotDocumentOptimizeInstructionsSchema, - fixedValues: { - robot: '/document/optimize', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'preset', kind: 'string' }, - { name: 'image_dpi', kind: 'number' }, - { name: 'compress_fonts', kind: 'boolean' }, - { name: 'subset_fonts', kind: 'boolean' }, - { name: 'remove_metadata', kind: 'boolean' }, - { name: 'linearize', kind: 'boolean' }, - { name: 'compatibility', kind: 'string' }, - ], - rawValues: { - preset: this.preset, - image_dpi: this.imageDpi, - compress_fonts: this.compressFonts, - subset_fonts: this.subsetFonts, - remove_metadata: this.removeMetadata, - linearize: this.linearize, - compatibility: this.compatibility, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - optimized: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotDocumentOptimizeInstructionsSchema, + fixedValues: { + robot: '/document/optimize', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'preset', kind: 'string' }, + { name: 'image_dpi', kind: 'number' }, + { name: 'compress_fonts', kind: 'boolean' }, + { name: 'subset_fonts', kind: 'boolean' }, + { name: 'remove_metadata', kind: 'boolean' }, + { name: 'linearize', kind: 'boolean' }, + { name: 'compatibility', kind: 'string' }, + ], + rawValues: { + preset: this.preset, + image_dpi: this.imageDpi, + compress_fonts: this.compressFonts, + subset_fonts: this.subsetFonts, + remove_metadata: this.removeMetadata, + linearize: this.linearize, + compatibility: this.compatibility, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + optimized: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -1244,7 +1383,11 @@ export class DocumentAutoRotateCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -1278,8 +1421,8 @@ export class DocumentAutoRotateCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('document auto-rotate requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('document auto-rotate requires --input or --input-base64') return 1 } @@ -1288,33 +1431,47 @@ export class DocumentAutoRotateCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotDocumentAutorotateInstructionsSchema, - fixedValues: { - robot: '/document/autorotate', - result: true, - use: ':original', - }, - fieldSpecs: [], - rawValues: {}, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - autorotated: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotDocumentAutorotateInstructionsSchema, + fixedValues: { + robot: '/document/autorotate', + result: true, + use: ':original', + }, + fieldSpecs: [], + rawValues: {}, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + autorotated: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -1398,7 +1555,11 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -1432,8 +1593,8 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('document thumbs requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('document thumbs requires --input or --input-base64') return 1 } @@ -1442,63 +1603,77 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotDocumentThumbsInstructionsSchema, - fixedValues: { - robot: '/document/thumbs', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'page', kind: 'number' }, - { name: 'format', kind: 'string' }, - { name: 'delay', kind: 'number' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'alpha', kind: 'string' }, - { name: 'density', kind: 'string' }, - { name: 'antialiasing', kind: 'boolean' }, - { name: 'colorspace', kind: 'string' }, - { name: 'trim_whitespace', kind: 'boolean' }, - { name: 'pdf_use_cropbox', kind: 'boolean' }, - { name: 'turbo', kind: 'boolean' }, - ], - rawValues: { - page: this.page, - format: this.format, - delay: this.delay, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - alpha: this.alpha, - density: this.density, - antialiasing: this.antialiasing, - colorspace: this.colorspace, - trim_whitespace: this.trimWhitespace, - pdf_use_cropbox: this.pdfUseCropbox, - turbo: this.turbo, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - thumbnailed: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotDocumentThumbsInstructionsSchema, + fixedValues: { + robot: '/document/thumbs', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'page', kind: 'number' }, + { name: 'format', kind: 'string' }, + { name: 'delay', kind: 'number' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'alpha', kind: 'string' }, + { name: 'density', kind: 'string' }, + { name: 'antialiasing', kind: 'boolean' }, + { name: 'colorspace', kind: 'string' }, + { name: 'trim_whitespace', kind: 'boolean' }, + { name: 'pdf_use_cropbox', kind: 'boolean' }, + { name: 'turbo', kind: 'boolean' }, + ], + rawValues: { + page: this.page, + format: this.format, + delay: this.delay, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + alpha: this.alpha, + density: this.density, + antialiasing: this.antialiasing, + colorspace: this.colorspace, + trim_whitespace: this.trimWhitespace, + pdf_use_cropbox: this.pdfUseCropbox, + turbo: this.turbo, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + thumbnailed: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'directory', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -1630,7 +1805,11 @@ export class AudioWaveformCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -1664,8 +1843,8 @@ export class AudioWaveformCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('audio waveform requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('audio waveform requires --input or --input-base64') return 1 } @@ -1674,85 +1853,99 @@ export class AudioWaveformCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotAudioWaveformInstructionsSchema, - fixedValues: { - robot: '/audio/waveform', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'antialiasing', kind: 'auto' }, - { name: 'background_color', kind: 'string' }, - { name: 'center_color', kind: 'string' }, - { name: 'outer_color', kind: 'string' }, - { name: 'style', kind: 'string' }, - { name: 'split_channels', kind: 'boolean' }, - { name: 'zoom', kind: 'number' }, - { name: 'pixels_per_second', kind: 'number' }, - { name: 'bits', kind: 'number' }, - { name: 'start', kind: 'number' }, - { name: 'end', kind: 'number' }, - { name: 'colors', kind: 'string' }, - { name: 'border_color', kind: 'string' }, - { name: 'waveform_style', kind: 'string' }, - { name: 'bar_width', kind: 'number' }, - { name: 'bar_gap', kind: 'number' }, - { name: 'bar_style', kind: 'string' }, - { name: 'axis_label_color', kind: 'string' }, - { name: 'no_axis_labels', kind: 'boolean' }, - { name: 'with_axis_labels', kind: 'boolean' }, - { name: 'amplitude_scale', kind: 'number' }, - { name: 'compression', kind: 'number' }, - ], - rawValues: { - format: this.format, - width: this.width, - height: this.height, - antialiasing: this.antialiasing, - background_color: this.backgroundColor, - center_color: this.centerColor, - outer_color: this.outerColor, - style: this.style, - split_channels: this.splitChannels, - zoom: this.zoom, - pixels_per_second: this.pixelsPerSecond, - bits: this.bits, - start: this.start, - end: this.end, - colors: this.colors, - border_color: this.borderColor, - waveform_style: this.waveformStyle, - bar_width: this.barWidth, - bar_gap: this.barGap, - bar_style: this.barStyle, - axis_label_color: this.axisLabelColor, - no_axis_labels: this.noAxisLabels, - with_axis_labels: this.withAxisLabels, - amplitude_scale: this.amplitudeScale, - compression: this.compression, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - waveformed: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotAudioWaveformInstructionsSchema, + fixedValues: { + robot: '/audio/waveform', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'antialiasing', kind: 'auto' }, + { name: 'background_color', kind: 'string' }, + { name: 'center_color', kind: 'string' }, + { name: 'outer_color', kind: 'string' }, + { name: 'style', kind: 'string' }, + { name: 'split_channels', kind: 'boolean' }, + { name: 'zoom', kind: 'number' }, + { name: 'pixels_per_second', kind: 'number' }, + { name: 'bits', kind: 'number' }, + { name: 'start', kind: 'number' }, + { name: 'end', kind: 'number' }, + { name: 'colors', kind: 'string' }, + { name: 'border_color', kind: 'string' }, + { name: 'waveform_style', kind: 'string' }, + { name: 'bar_width', kind: 'number' }, + { name: 'bar_gap', kind: 'number' }, + { name: 'bar_style', kind: 'string' }, + { name: 'axis_label_color', kind: 'string' }, + { name: 'no_axis_labels', kind: 'boolean' }, + { name: 'with_axis_labels', kind: 'boolean' }, + { name: 'amplitude_scale', kind: 'number' }, + { name: 'compression', kind: 'number' }, + ], + rawValues: { + format: this.format, + width: this.width, + height: this.height, + antialiasing: this.antialiasing, + background_color: this.backgroundColor, + center_color: this.centerColor, + outer_color: this.outerColor, + style: this.style, + split_channels: this.splitChannels, + zoom: this.zoom, + pixels_per_second: this.pixelsPerSecond, + bits: this.bits, + start: this.start, + end: this.end, + colors: this.colors, + border_color: this.borderColor, + waveform_style: this.waveformStyle, + bar_width: this.barWidth, + bar_gap: this.barGap, + bar_style: this.barStyle, + axis_label_color: this.axisLabelColor, + no_axis_labels: this.noAxisLabels, + with_axis_labels: this.withAxisLabels, + amplitude_scale: this.amplitudeScale, + compression: this.compression, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + waveformed: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -1798,7 +1991,11 @@ export class TextSpeakCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -1832,7 +2029,11 @@ export class TextSpeakCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && this.prompt == null) { + if ( + (this.inputs ?? []).length === 0 && + (this.inputBase64 ?? []).length === 0 && + this.prompt == null + ) { this.output.error('text speak requires --input or --prompt') return 1 } @@ -1842,53 +2043,67 @@ export class TextSpeakCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotTextSpeakInstructionsSchema, - fixedValues: - (this.inputs ?? []).length > 0 - ? { - ...{ + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], + }) + + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } + + try { + const step = parseIntentStep({ + schema: robotTextSpeakInstructionsSchema, + fixedValues: + (this.inputs ?? []).length > 0 + ? { + ...{ + robot: '/text/speak', + result: true, + }, + use: ':original', + } + : { robot: '/text/speak', result: true, }, - use: ':original', - } - : { - robot: '/text/speak', - result: true, - }, - fieldSpecs: [ - { name: 'prompt', kind: 'string' }, - { name: 'provider', kind: 'string' }, - { name: 'target_language', kind: 'string' }, - { name: 'voice', kind: 'string' }, - { name: 'ssml', kind: 'boolean' }, - ], - rawValues: { - prompt: this.prompt, - provider: this.provider, - target_language: this.targetLanguage, - voice: this.voice, - ssml: this.ssml, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - synthesized: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + fieldSpecs: [ + { name: 'prompt', kind: 'string' }, + { name: 'provider', kind: 'string' }, + { name: 'target_language', kind: 'string' }, + { name: 'voice', kind: 'string' }, + { name: 'ssml', kind: 'boolean' }, + ], + rawValues: { + prompt: this.prompt, + provider: this.provider, + target_language: this.targetLanguage, + voice: this.voice, + ssml: this.ssml, + }, + }) - return hasFailures ? 1 : undefined + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + synthesized: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -1942,7 +2157,11 @@ export class VideoThumbsCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -1976,8 +2195,8 @@ export class VideoThumbsCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('video thumbs requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('video thumbs requires --input or --input-base64') return 1 } @@ -1986,51 +2205,65 @@ export class VideoThumbsCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotVideoThumbsInstructionsSchema, - fixedValues: { - robot: '/video/thumbs', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'count', kind: 'number' }, - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'rotate', kind: 'number' }, - { name: 'input_codec', kind: 'string' }, - ], - rawValues: { - count: this.count, - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - rotate: this.rotate, - input_codec: this.inputCodec, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - thumbnailed: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotVideoThumbsInstructionsSchema, + fixedValues: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + fieldSpecs: [ + { name: 'count', kind: 'number' }, + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'rotate', kind: 'number' }, + { name: 'input_codec', kind: 'string' }, + ], + rawValues: { + count: this.count, + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + rotate: this.rotate, + input_codec: this.inputCodec, + }, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + thumbnailed: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'directory', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -2046,7 +2279,11 @@ export class VideoEncodeHlsCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -2080,8 +2317,8 @@ export class VideoEncodeHlsCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('video encode-hls requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('video encode-hls requires --input or --input-base64') return 1 } @@ -2090,20 +2327,34 @@ export class VideoEncodeHlsCommand extends AuthenticatedCommand { return 1 } - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - template: 'builtin/encode-hls-video@latest', - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - return hasFailures ? 1 : undefined + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } + + try { + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + template: 'builtin/encode-hls-video@latest', + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'directory', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -2149,7 +2400,11 @@ export class FileCompressCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide one or more input files or directories', + description: 'Provide one or more input paths, directories, URLs, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -2170,53 +2425,62 @@ export class FileCompressCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('file compress requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('file compress requires --input or --input-base64') return 1 } - const step = parseIntentStep({ - schema: robotFileCompressInstructionsSchema, - fixedValues: { - robot: '/file/compress', - result: true, - use: { - steps: [':original'], - bundle_steps: true, - }, - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'gzip', kind: 'boolean' }, - { name: 'password', kind: 'string' }, - { name: 'compression_level', kind: 'number' }, - { name: 'file_layout', kind: 'string' }, - { name: 'archive_name', kind: 'string' }, - ], - rawValues: { - format: this.format, - gzip: this.gzip, - password: this.password, - compression_level: this.compressionLevel, - file_layout: this.fileLayout, - archive_name: this.archiveName, - }, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - compressed: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: true, - }) + try { + const step = parseIntentStep({ + schema: robotFileCompressInstructionsSchema, + fixedValues: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'gzip', kind: 'boolean' }, + { name: 'password', kind: 'string' }, + { name: 'compression_level', kind: 'number' }, + { name: 'file_layout', kind: 'string' }, + { name: 'archive_name', kind: 'string' }, + ], + rawValues: { + format: this.format, + gzip: this.gzip, + password: this.password, + compression_level: this.compressionLevel, + file_layout: this.fileLayout, + archive_name: this.archiveName, + }, + }) - return hasFailures ? 1 : undefined + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + compressed: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'file', + recursive: this.recursive, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: true, + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } @@ -2231,7 +2495,11 @@ export class FileDecompressCommand extends AuthenticatedCommand { }) inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', }) recursive = Option.Boolean('--recursive,-r', false, { @@ -2265,8 +2533,8 @@ export class FileDecompressCommand extends AuthenticatedCommand { }) protected async run(): Promise { - if ((this.inputs ?? []).length === 0) { - this.output.error('file decompress requires at least one --input') + if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { + this.output.error('file decompress requires --input or --input-base64') return 1 } @@ -2275,33 +2543,47 @@ export class FileDecompressCommand extends AuthenticatedCommand { return 1 } - const step = parseIntentStep({ - schema: robotFileDecompressInstructionsSchema, - fixedValues: { - robot: '/file/decompress', - result: true, - use: ':original', - }, - fieldSpecs: [], - rawValues: {}, + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], }) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - decompressed: step, - }, - inputs: this.inputs ?? [], - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } - return hasFailures ? 1 : undefined + try { + const step = parseIntentStep({ + schema: robotFileDecompressInstructionsSchema, + fixedValues: { + robot: '/file/decompress', + result: true, + use: ':original', + }, + fieldSpecs: [], + rawValues: {}, + }) + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + stepsData: { + decompressed: step, + }, + inputs: preparedInputs.inputs, + output: this.outputPath, + outputMode: 'directory', + recursive: this.recursive, + watch: this.watch, + del: this.deleteAfterProcessing, + reprocessStale: this.reprocessStale, + singleAssembly: this.singleAssembly, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } } } diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index bd315f14..7ae70749 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -29,7 +29,10 @@ import { robotFileDecompressInstructionsSchema, meta as robotFileDecompressMeta, } from '../alphalib/types/robots/file-decompress.ts' -import { robotFilePreviewInstructionsSchema } from '../alphalib/types/robots/file-preview.ts' +import { + robotFilePreviewInstructionsSchema, + meta as robotFilePreviewMeta, +} from '../alphalib/types/robots/file-preview.ts' import { robotImageBgremoveInstructionsSchema, meta as robotImageBgremoveMeta, @@ -71,6 +74,7 @@ export interface RobotIntentCatalogEntry { defaultSingleAssembly?: boolean inputMode?: Exclude outputMode?: IntentOutputMode + paths?: string[] robot: keyof typeof robotIntentDefinitions } @@ -156,6 +160,13 @@ export const robotIntentDefinitions = { schemaImportName: 'robotFileDecompressInstructionsSchema', schemaImportPath: '../../alphalib/types/robots/file-decompress.ts', }, + '/file/preview': { + robot: '/file/preview', + meta: robotFilePreviewMeta, + schema: robotFilePreviewInstructionsSchema, + schemaImportName: 'robotFilePreviewInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/file-preview.ts', + }, '/image/bgremove': { robot: '/image/bgremove', meta: robotImageBgremoveMeta, @@ -200,32 +211,11 @@ export const robotIntentDefinitions = { }, } satisfies Record -export const intentRecipeDefinitions = { - 'preview-generate': { - summary: 'Generate preview images for remote file URLs', - description: 'Generate a preview image for a remote file URL', - details: - 'Imports a remote file with `/http/import`, then runs `/file/preview` and downloads the preview to `--out`.', - paths: ['preview', 'generate'], - inputMode: 'remote-url', - outputDescription: 'Write the generated preview image to this path', - outputRequired: true, - examples: [ - [ - 'Preview a remote PDF', - 'transloadit preview generate --input https://example.com/file.pdf --width 300 --height 200 --out preview.png', - ], - ], - schema: robotFilePreviewInstructionsSchema, - schemaImportName: 'robotFilePreviewInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/file-preview.ts', - resultStepName: 'preview', - }, -} satisfies Record +export const intentRecipeDefinitions = {} satisfies Record export const intentCatalog = [ { kind: 'robot', robot: '/image/generate' }, - { kind: 'recipe', recipe: 'preview-generate' }, + { kind: 'robot', robot: '/file/preview', paths: ['preview', 'generate'] }, { kind: 'robot', robot: '/image/bgremove' }, { kind: 'robot', robot: '/image/optimize' }, { kind: 'robot', robot: '/image/resize' }, diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 2ac92210..9dff459f 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -1,5 +1,8 @@ +import { basename } from 'node:path' import type { z } from 'zod' +import { prepareInputFiles } from '../inputFiles.ts' + export type IntentFieldKind = 'auto' | 'boolean' | 'number' | 'string' export interface IntentFieldSpec { @@ -7,6 +10,120 @@ export interface IntentFieldSpec { name: string } +export interface PreparedIntentInputs { + cleanup: Array<() => Promise> + hasTransientInputs: boolean + inputs: string[] +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +function normalizeBase64Value(value: string): string { + const trimmed = value.trim() + const marker = ';base64,' + const markerIndex = trimmed.indexOf(marker) + if (!trimmed.startsWith('data:') || markerIndex === -1) { + return trimmed + } + + return trimmed.slice(markerIndex + marker.length) +} + +export async function prepareIntentInputs({ + inputBase64Values, + inputValues, +}: { + inputBase64Values: string[] + inputValues: string[] +}): Promise { + const preparedOrder: string[] = [] + const syntheticInputs: Array< + | { + base64: string + field: string + filename: string + kind: 'base64' + } + | { + field: string + kind: 'url' + url: string + } + > = [] + + for (const value of inputValues) { + if (!isHttpUrl(value)) { + preparedOrder.push(value) + continue + } + + const field = `input_url_${syntheticInputs.length + 1}` + syntheticInputs.push({ + kind: 'url', + field, + url: value, + }) + preparedOrder.push(field) + } + + for (const [index, value] of inputBase64Values.entries()) { + const field = `input_base64_${index + 1}` + const filename = `input-base64-${index + 1}.bin` + syntheticInputs.push({ + kind: 'base64', + field, + filename, + base64: normalizeBase64Value(value), + }) + preparedOrder.push(field) + } + + if (syntheticInputs.length === 0) { + return { + cleanup: [], + hasTransientInputs: false, + inputs: preparedOrder, + } + } + + const prepared = await prepareInputFiles({ + inputFiles: syntheticInputs.map((input) => { + if (input.kind === 'url') { + return { + kind: 'url' as const, + field: input.field, + url: input.url, + filename: basename(new URL(input.url).pathname) || undefined, + } + } + + return { + kind: 'base64' as const, + field: input.field, + base64: input.base64, + filename: input.filename, + } + }), + base64Strategy: 'tempfile', + urlStrategy: 'download', + }) + + const inputs = preparedOrder.map((value) => prepared.files[value] ?? value) + + return { + cleanup: prepared.cleanup, + hasTransientInputs: true, + inputs, + } +} + export function coerceIntentFieldValue( kind: IntentFieldKind, raw: string, diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index f0ff99a1..b24a583e 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -1,3 +1,4 @@ +import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' import * as assembliesCommands from '../../../src/cli/commands/assemblies.ts' @@ -17,6 +18,7 @@ const resetExitCode = () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() + nock.cleanAll() resetExitCode() }) @@ -65,7 +67,7 @@ describe('intent commands', () => { ) }) - it('maps preview generate flags to /http/import + /file/preview steps', async () => { + it('maps preview generate flags to /file/preview step parameters', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -80,7 +82,7 @@ describe('intent commands', () => { 'preview', 'generate', '--input', - 'https://example.com/file.pdf', + 'document.pdf', '--width', '320', '--height', @@ -96,17 +98,13 @@ describe('intent commands', () => { expect.any(OutputCtl), expect.anything(), expect.objectContaining({ - inputs: [], + inputs: ['document.pdf'], output: 'preview.jpg', stepsData: { - imported: { - robot: '/http/import', - url: 'https://example.com/file.pdf', - }, preview: expect.objectContaining({ robot: '/file/preview', result: true, - use: 'imported', + use: ':original', width: 320, height: 200, format: 'jpg', @@ -116,6 +114,82 @@ describe('intent commands', () => { ) }) + it('downloads URL inputs for preview generate before calling assemblies create', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + nock('https://example.com').get('/file.pdf').reply(200, 'pdf-data') + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'preview', + 'generate', + '--input', + 'https://example.com/file.pdf', + '--out', + 'preview.png', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [expect.stringContaining('transloadit-input-')], + stepsData: { + preview: expect.objectContaining({ + robot: '/file/preview', + use: ':original', + }), + }, + }), + ) + }) + + it('supports base64 inputs for intent commands', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'document', + 'convert', + '--input-base64', + Buffer.from('hello world').toString('base64'), + '--format', + 'pdf', + '--out', + 'output.pdf', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [expect.stringContaining('transloadit-input-')], + stepsData: { + converted: expect.objectContaining({ + robot: '/document/convert', + use: ':original', + format: 'pdf', + }), + }, + }), + ) + }) + it('maps video encode-hls to the builtin template', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') From 84ba82f281adb46cec0c5d4abad7778aba8250d0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 26 Mar 2026 16:05:42 +0100 Subject: [PATCH 11/44] fix(node-cli): unblock CI for intent commands --- docs/fingerprint/transloadit-baseline.json | 133 ++++++++++++++---- .../transloadit-baseline.package.json | 7 +- .../node/scripts/generate-intent-commands.ts | 2 +- .../src/cli/commands/generated-intents.ts | 30 ++-- packages/node/src/cli/intentRuntime.ts | 2 +- .../test/unit/cli/assemblies-create.test.ts | 2 +- packages/node/test/unit/cli/intents.test.ts | 22 ++- packages/transloadit/package.json | 9 +- 8 files changed, 147 insertions(+), 60 deletions(-) diff --git a/docs/fingerprint/transloadit-baseline.json b/docs/fingerprint/transloadit-baseline.json index 521052f5..8b980d1c 100644 --- a/docs/fingerprint/transloadit-baseline.json +++ b/docs/fingerprint/transloadit-baseline.json @@ -1,9 +1,9 @@ { - "packageDir": "/home/kvz/code/node-sdk/packages/transloadit", + "packageDir": "/Users/kvz/code/node-sdk/packages/transloadit", "tarball": { "filename": "transloadit-4.7.5.tgz", - "sizeBytes": 1250742, - "sha256": "195c48c7b93e44360d29e3c74d3dbb720503242123a7a57c3387000c71b72c1a" + "sizeBytes": 1319165, + "sha256": "e398ad0369c894bf96433646ca1831a45b33b733905cdd30fd8d031573d8f25b" }, "packageJson": { "name": "transloadit", @@ -48,8 +48,8 @@ }, { "path": "dist/cli/commands/assemblies.js", - "sizeBytes": 51217, - "sha256": "c368505ba2086dfbcc6148c5ac656a9ac228093cf8cebe95ba650f4dfe21592d" + "sizeBytes": 51973, + "sha256": "e9ac5395852192082f1a19cf1c0e33b0a3b60c6a6cf3df76de4e73fb53703f6a" }, { "path": "dist/alphalib/types/assembliesGet.js", @@ -326,6 +326,11 @@ "sizeBytes": 3534, "sha256": "c4bd648bb097acadbc349406192105367b9d94c516700b99c9f4d7a4b6c7a6f0" }, + { + "path": "dist/cli/commands/generated-intents.js", + "sizeBytes": 117625, + "sha256": "cfb0e934d6e426151f51d1f28519a2dcaafb4c68c0ebae9a8959f96999d2dfcf" + }, { "path": "dist/alphalib/types/robots/google-import.js", "sizeBytes": 3748, @@ -398,14 +403,24 @@ }, { "path": "dist/cli/commands/index.js", - "sizeBytes": 2145, - "sha256": "b44764be9d6a803669bbc1a937f553566ce91993ed283c7f6d5ef65cbff6b263" + "sizeBytes": 2312, + "sha256": "a11ca4773963c91d8d03123b9e2e7a2a5d268880e1bae18f0419df9a36adfb26" }, { "path": "dist/inputFiles.js", "sizeBytes": 7836, "sha256": "1d77d129abc1b11be894d1cf6c34afc93370165e39871d6d5b672c058d1a0489" }, + { + "path": "dist/cli/intentCommandSpecs.js", + "sizeBytes": 7199, + "sha256": "12a812c6efd4697b45053d9d7a60b2cf4c87c4aa3a497f493b21c53f1affe0d5" + }, + { + "path": "dist/cli/intentRuntime.js", + "sizeBytes": 4416, + "sha256": "06cfff14909b48c57dd0aec481b0d45340441dd1a59de3348b9b23a45cfc0415" + }, { "path": "dist/lintAssemblyInput.js", "sizeBytes": 2335, @@ -599,7 +614,7 @@ { "path": "dist/Transloadit.js", "sizeBytes": 37922, - "sha256": "500d82f5b654da175e301294540522718b2a81e15d87c3cd365f074fe961a769" + "sha256": "da28e944dd0a9cadb5a2cecdb2d859a639a53abd4d45489fab02069115918b6a" }, { "path": "dist/alphalib/tryCatch.js", @@ -698,8 +713,8 @@ }, { "path": "package.json", - "sizeBytes": 2730, - "sha256": "313dd2ac13d3e4857b71bd889b2c9fa7f2458cf2bf5be2dd5a1996eb3d23199d" + "sizeBytes": 2777, + "sha256": "a0d72a6f0de8270f450f8ae25ec279b7b933735063940df62f90eb09711688a0" }, { "path": "dist/alphalib/types/robots/_index.d.ts.map", @@ -753,13 +768,13 @@ }, { "path": "dist/cli/commands/assemblies.d.ts.map", - "sizeBytes": 3737, - "sha256": "e659be90cee8252d9fa4a5db72cf3d48d2548d0f5a716368cc024f7ed1e4b222" + "sizeBytes": 3877, + "sha256": "a7780be849e81aaa345c859f224462a6d36faefdd2a0f8ea91b8f94a505437ef" }, { "path": "dist/cli/commands/assemblies.js.map", - "sizeBytes": 44866, - "sha256": "8bc2496707790b60dfde07065b6df6adc7152d04e999ed4c84f1993eaeadc28f" + "sizeBytes": 46288, + "sha256": "cf8b96797224c7dc9724bf93100a778967f92288e0a0ae1d3a1a9028e7b50386" }, { "path": "dist/alphalib/types/assembliesGet.d.ts.map", @@ -1311,6 +1326,16 @@ "sizeBytes": 2145, "sha256": "ce1bf48c1cc713ae843061cba3c3b119475baa5cb6b62ac4b575e50b297bcf71" }, + { + "path": "dist/cli/commands/generated-intents.d.ts.map", + "sizeBytes": 6612, + "sha256": "9335d244c8e1414ad5b4186fc4b7bc86cf10c33da7dbd6521de5ae4d8c7b4108" + }, + { + "path": "dist/cli/commands/generated-intents.js.map", + "sizeBytes": 66461, + "sha256": "e4c0b89607638191017a161b2c253a57663ebaff81800c6c4b33618a1129e75b" + }, { "path": "dist/alphalib/types/robots/google-import.d.ts.map", "sizeBytes": 960, @@ -1454,12 +1479,12 @@ { "path": "dist/cli/commands/index.d.ts.map", "sizeBytes": 198, - "sha256": "3f955192e7d7832d6fd0c8ee0244b153e42c947686425750c7c8c58d6657f2a7" + "sha256": "6a459d827f048c87854b1570a2215cd69dc696ebe809a695a4d633e9dd4541ca" }, { "path": "dist/cli/commands/index.js.map", - "sizeBytes": 1940, - "sha256": "1cad8333ee5fd6c34071a6d8528a7b55399be0626baf1754e28453d714836868" + "sizeBytes": 2088, + "sha256": "5e514ba662ee52294dc9b50a7744fa3c8d89f60d0f49eaa118d30e9b16bfcb39" }, { "path": "dist/inputFiles.d.ts.map", @@ -1471,6 +1496,26 @@ "sizeBytes": 8595, "sha256": "fa96090c58247759bef9b7767bd4b4f474bba332ee5a6edf0429e89e99a0c25c" }, + { + "path": "dist/cli/intentCommandSpecs.d.ts.map", + "sizeBytes": 5804, + "sha256": "8179d0aba494de60e0b90dd4d880c1875e3df3053ff11ed76b9cd68de1245f9d" + }, + { + "path": "dist/cli/intentCommandSpecs.js.map", + "sizeBytes": 4171, + "sha256": "1e54470578a751319fbac64a4d91a8013dfb6137952aa26f18c7e2f8bfef9fb6" + }, + { + "path": "dist/cli/intentRuntime.d.ts.map", + "sizeBytes": 950, + "sha256": "fb6f5fe96ddb695919494e58741d33c251e7d1b458874a604edf65988aa9bab9" + }, + { + "path": "dist/cli/intentRuntime.js.map", + "sizeBytes": 4469, + "sha256": "3dd4065e8c0a72148f15e044af0565fafceaee7b55a270c0d5f6d5e8f214eb79" + }, { "path": "dist/lintAssemblyInput.d.ts.map", "sizeBytes": 522, @@ -1854,12 +1899,12 @@ { "path": "dist/Transloadit.d.ts.map", "sizeBytes": 6679, - "sha256": "ee51b85a546a35f49fd8512705d9bd090d704edd94757ed6f457b882e9bc2396" + "sha256": "319e3cf611757159752a324d59ca0f6fa02a8218e32e61c8ffb103764812a9e0" }, { "path": "dist/Transloadit.js.map", "sizeBytes": 27586, - "sha256": "9fd1ee82626e9e2452ec799d3a8ae775f4a7c1fd9b99d9703f7e3e2bd0b3d191" + "sha256": "409d5759a0e57719a00e5ab6314a89a49aa083e6bc335078e4e12fb9c046a41c" }, { "path": "dist/alphalib/tryCatch.d.ts.map", @@ -2053,8 +2098,8 @@ }, { "path": "README.md", - "sizeBytes": 36476, - "sha256": "62cf02f92243b72419d266b5e94adc7f06cbf55fc6155c5ecf67115afdc47635" + "sizeBytes": 37376, + "sha256": "71e16691f95885bbd342ed8f02a8c447c968b6034fb8f16b35911ab7462abff9" }, { "path": "dist/alphalib/types/robots/_index.d.ts", @@ -2108,13 +2153,13 @@ }, { "path": "dist/cli/commands/assemblies.d.ts", - "sizeBytes": 4342, - "sha256": "df6486047bbd89862b7cb433d05f63a128c1fad4520df978842adcecd4f17503" + "sizeBytes": 4500, + "sha256": "2ae7e9403ca1045ae511aa4d6b2b6082582bc9ef89d1f5887c6aafc4e731d586" }, { "path": "src/cli/commands/assemblies.ts", - "sizeBytes": 50948, - "sha256": "d2a9de8dbd22233785a9880537ece31c0123b1959a24048b50b87c8a759db10e" + "sizeBytes": 51861, + "sha256": "2ec97034f025676083dca02198437695a8a315f3c33eb0a12e9d28ffacd0fe8c" }, { "path": "dist/alphalib/types/assembliesGet.d.ts", @@ -2666,6 +2711,16 @@ "sizeBytes": 4197, "sha256": "1bbaa2361cc3675a29178cbd0f4fcecaad1033032f154a6da36c5c677a9c9447" }, + { + "path": "dist/cli/commands/generated-intents.d.ts", + "sizeBytes": 12769, + "sha256": "9b9c0bc70c99b952bae43dc7198bd312f4a55a194d9cfac201fff41499f4ec98" + }, + { + "path": "src/cli/commands/generated-intents.ts", + "sizeBytes": 108639, + "sha256": "0a3e20c1d14a9d9b66d1be3c6cc78343807f7588ec2f160fc46a6af65833d5b6" + }, { "path": "dist/alphalib/types/robots/google-import.d.ts", "sizeBytes": 9781, @@ -2813,8 +2868,8 @@ }, { "path": "src/cli/commands/index.ts", - "sizeBytes": 2044, - "sha256": "b6752fa800c6a91e662b75a0c0973f0ba513f263d4a96d5e46a0d3e1f1a9f828" + "sizeBytes": 2200, + "sha256": "dcf03b6ac54bf0793a6be2cc945d8b8e3173d5de69366b19d78d960e4e1e8d2f" }, { "path": "dist/inputFiles.d.ts", @@ -2826,6 +2881,26 @@ "sizeBytes": 8411, "sha256": "0df54cb83ac5c718f3d3f78ffb77a31d485e2ab5f0a9d91b4f64852e72d1a589" }, + { + "path": "dist/cli/intentCommandSpecs.d.ts", + "sizeBytes": 247937, + "sha256": "c145a6b21cfa8d5b6e92ddbc8e7e8ce1b3c037019c42c778f57460cf3a028ed3" + }, + { + "path": "src/cli/intentCommandSpecs.ts", + "sizeBytes": 8301, + "sha256": "12176f47a80112e90409bd858864e0e36592de6e52a60e5c1d8ab034569eee41" + }, + { + "path": "dist/cli/intentRuntime.d.ts", + "sizeBytes": 846, + "sha256": "9df958e592877cbf4ea4037110a8c3ed42c9a7ae98845c7ea039481d7d8b39b3" + }, + { + "path": "src/cli/intentRuntime.ts", + "sizeBytes": 4877, + "sha256": "8ec93528e4611fa86ba213e17a80c78615a50a01e2cd9440fb9e15aeb83b0445" + }, { "path": "src/alphalib/typings/json-to-ast.d.ts", "sizeBytes": 760, @@ -3214,12 +3289,12 @@ { "path": "dist/Transloadit.d.ts", "sizeBytes": 12397, - "sha256": "b1e9233014c13c47832c7fb8b2c82bc75e1b3519f259b3ce71f9bd6d8150f36d" + "sha256": "b5d21acd74ea575bc5c9820ba48d736cd0f44a025f4981aa22d4085007fdf736" }, { "path": "src/Transloadit.ts", "sizeBytes": 42665, - "sha256": "d8a3d50a5f245e79258bada7ca39cc9aaedbe430b521145c819b0d46d3fcb1bf" + "sha256": "c6fc410d37595c38306b6e73ca5ff7aa3ea56a2571f23f6800c4f46875df87e4" }, { "path": "dist/alphalib/tryCatch.d.ts", diff --git a/docs/fingerprint/transloadit-baseline.package.json b/docs/fingerprint/transloadit-baseline.package.json index b1621636..0f1dab7b 100644 --- a/docs/fingerprint/transloadit-baseline.package.json +++ b/docs/fingerprint/transloadit-baseline.package.json @@ -70,13 +70,14 @@ "src": "./src" }, "scripts": { - "check": "yarn lint:ts && yarn fix && yarn test:unit", + "sync:intents": "node scripts/generate-intent-commands.ts", + "check": "yarn sync:intents && yarn lint:ts && yarn fix && yarn test:unit", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", "lint:js": "biome check .", - "lint": "npm-run-all --parallel 'lint:js'", - "fix": "npm-run-all --serial 'fix:js'", + "lint": "yarn lint:js", + "fix": "yarn fix:js", "lint:deps": "knip --dependencies --no-progress", "fix:deps": "knip --dependencies --no-progress --fix", "prepack": "node ../../scripts/prepare-transloadit.ts", diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 733221a2..b41a2644 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -1025,7 +1025,7 @@ function generateClass(spec: ResolvedIntentCommandSpec): string { const runBody = formatRunBody(spec, fieldSpecs) return ` -export class ${spec.className} extends AuthenticatedCommand { +class ${spec.className} extends AuthenticatedCommand { static override paths = ${JSON.stringify([spec.paths])} static override usage = Command.Usage({ diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index e18e6bcb..a8b719ea 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -22,7 +22,7 @@ import { parseIntentStep, prepareIntentInputs } from '../intentRuntime.ts' import * as assembliesCommands from './assemblies.ts' import { AuthenticatedCommand } from './BaseCommand.ts' -export class ImageGenerateCommand extends AuthenticatedCommand { +class ImageGenerateCommand extends AuthenticatedCommand { static override paths = [['image', 'generate']] static override usage = Command.Usage({ @@ -122,7 +122,7 @@ export class ImageGenerateCommand extends AuthenticatedCommand { } } -export class PreviewGenerateCommand extends AuthenticatedCommand { +class PreviewGenerateCommand extends AuthenticatedCommand { static override paths = [['preview', 'generate']] static override usage = Command.Usage({ @@ -386,7 +386,7 @@ export class PreviewGenerateCommand extends AuthenticatedCommand { } } -export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { +class ImageRemoveBackgroundCommand extends AuthenticatedCommand { static override paths = [['image', 'remove-background']] static override usage = Command.Usage({ @@ -518,7 +518,7 @@ export class ImageRemoveBackgroundCommand extends AuthenticatedCommand { } } -export class ImageOptimizeCommand extends AuthenticatedCommand { +class ImageOptimizeCommand extends AuthenticatedCommand { static override paths = [['image', 'optimize']] static override usage = Command.Usage({ @@ -653,7 +653,7 @@ export class ImageOptimizeCommand extends AuthenticatedCommand { } } -export class ImageResizeCommand extends AuthenticatedCommand { +class ImageResizeCommand extends AuthenticatedCommand { static override paths = [['image', 'resize']] static override usage = Command.Usage({ @@ -1041,7 +1041,7 @@ export class ImageResizeCommand extends AuthenticatedCommand { } } -export class DocumentConvertCommand extends AuthenticatedCommand { +class DocumentConvertCommand extends AuthenticatedCommand { static override paths = [['document', 'convert']] static override usage = Command.Usage({ @@ -1214,7 +1214,7 @@ export class DocumentConvertCommand extends AuthenticatedCommand { } } -export class DocumentOptimizeCommand extends AuthenticatedCommand { +class DocumentOptimizeCommand extends AuthenticatedCommand { static override paths = [['document', 'optimize']] static override usage = Command.Usage({ @@ -1370,7 +1370,7 @@ export class DocumentOptimizeCommand extends AuthenticatedCommand { } } -export class DocumentAutoRotateCommand extends AuthenticatedCommand { +class DocumentAutoRotateCommand extends AuthenticatedCommand { static override paths = [['document', 'auto-rotate']] static override usage = Command.Usage({ @@ -1475,7 +1475,7 @@ export class DocumentAutoRotateCommand extends AuthenticatedCommand { } } -export class DocumentThumbsCommand extends AuthenticatedCommand { +class DocumentThumbsCommand extends AuthenticatedCommand { static override paths = [['document', 'thumbs']] static override usage = Command.Usage({ @@ -1677,7 +1677,7 @@ export class DocumentThumbsCommand extends AuthenticatedCommand { } } -export class AudioWaveformCommand extends AuthenticatedCommand { +class AudioWaveformCommand extends AuthenticatedCommand { static override paths = [['audio', 'waveform']] static override usage = Command.Usage({ @@ -1949,7 +1949,7 @@ export class AudioWaveformCommand extends AuthenticatedCommand { } } -export class TextSpeakCommand extends AuthenticatedCommand { +class TextSpeakCommand extends AuthenticatedCommand { static override paths = [['text', 'speak']] static override usage = Command.Usage({ @@ -2107,7 +2107,7 @@ export class TextSpeakCommand extends AuthenticatedCommand { } } -export class VideoThumbsCommand extends AuthenticatedCommand { +class VideoThumbsCommand extends AuthenticatedCommand { static override paths = [['video', 'thumbs']] static override usage = Command.Usage({ @@ -2267,7 +2267,7 @@ export class VideoThumbsCommand extends AuthenticatedCommand { } } -export class VideoEncodeHlsCommand extends AuthenticatedCommand { +class VideoEncodeHlsCommand extends AuthenticatedCommand { static override paths = [['video', 'encode-hls']] static override usage = Command.Usage({ @@ -2358,7 +2358,7 @@ export class VideoEncodeHlsCommand extends AuthenticatedCommand { } } -export class FileCompressCommand extends AuthenticatedCommand { +class FileCompressCommand extends AuthenticatedCommand { static override paths = [['file', 'compress']] static override usage = Command.Usage({ @@ -2484,7 +2484,7 @@ export class FileCompressCommand extends AuthenticatedCommand { } } -export class FileDecompressCommand extends AuthenticatedCommand { +class FileDecompressCommand extends AuthenticatedCommand { static override paths = [['file', 'decompress']] static override usage = Command.Usage({ diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 9dff459f..ee16a06d 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -124,7 +124,7 @@ export async function prepareIntentInputs({ } } -export function coerceIntentFieldValue( +function coerceIntentFieldValue( kind: IntentFieldKind, raw: string, fieldSchema?: z.ZodTypeAny, diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 26d340b0..56ff1b9e 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -148,7 +148,7 @@ describe('assemblies create', () => { expect(client.createAssembly).toHaveBeenCalledTimes(1) const uploads = client.createAssembly.mock.calls[0]?.[0]?.uploads - expect(Object.keys(uploads ?? {})).toEqual(['a.txt', 'b.txt']) + expect(Object.keys(uploads ?? {}).sort()).toEqual(['a.txt', 'b.txt']) }) it('writes single-input directory outputs using result filenames', async () => { diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index b24a583e..7417a748 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -2,10 +2,7 @@ import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' import * as assembliesCommands from '../../../src/cli/commands/assemblies.ts' -import { - DocumentConvertCommand, - TextSpeakCommand, -} from '../../../src/cli/commands/generated-intents.ts' +import { intentCommands } from '../../../src/cli/commands/generated-intents.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' @@ -15,6 +12,19 @@ const resetExitCode = () => { process.exitCode = undefined } +function getIntentCommand(paths: string[]): (typeof intentCommands)[number] { + const command = intentCommands.find((candidate) => { + const candidatePaths = candidate.paths[0] + return candidatePaths != null && candidatePaths.join(' ') === paths.join(' ') + }) + + if (command == null) { + throw new Error(`No intent command found for ${paths.join(' ')}`) + } + + return command +} + afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() @@ -655,10 +665,10 @@ describe('intent commands', () => { }) it('includes required schema flags in generated usage examples', () => { - expect(DocumentConvertCommand.usage.examples).toEqual([ + expect(getIntentCommand(['document', 'convert']).usage.examples).toEqual([ ['Run the command', expect.stringContaining('--format')], ]) - expect(TextSpeakCommand.usage.examples).toEqual([ + expect(getIntentCommand(['text', 'speak']).usage.examples).toEqual([ ['Run the command', expect.stringContaining('--provider')], ]) }) diff --git a/packages/transloadit/package.json b/packages/transloadit/package.json index 63814af2..0f1dab7b 100644 --- a/packages/transloadit/package.json +++ b/packages/transloadit/package.json @@ -1,6 +1,6 @@ { "name": "transloadit", - "version": "4.7.4", + "version": "4.7.5", "description": "Node.js SDK for Transloadit", "homepage": "https://github.com/transloadit/node-sdk/tree/main/packages/node", "bugs": { @@ -70,13 +70,14 @@ "src": "./src" }, "scripts": { - "check": "yarn lint:ts && yarn fix && yarn test:unit", + "sync:intents": "node scripts/generate-intent-commands.ts", + "check": "yarn sync:intents && yarn lint:ts && yarn fix && yarn test:unit", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", "lint:js": "biome check .", - "lint": "npm-run-all --parallel 'lint:js'", - "fix": "npm-run-all --serial 'fix:js'", + "lint": "yarn lint:js", + "fix": "yarn fix:js", "lint:deps": "knip --dependencies --no-progress", "fix:deps": "knip --dependencies --no-progress --fix", "prepack": "node ../../scripts/prepare-transloadit.ts", From ecdc598ebdd2e16e3bb5c33fa10dfd0937a8a0fe Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 26 Mar 2026 16:15:34 +0100 Subject: [PATCH 12/44] chore(repo): drop stale cursor rule symlink --- .cursor/rules/pr-comments.mdc | 1 - 1 file changed, 1 deletion(-) delete mode 120000 .cursor/rules/pr-comments.mdc diff --git a/.cursor/rules/pr-comments.mdc b/.cursor/rules/pr-comments.mdc deleted file mode 120000 index 4b5e57d6..00000000 --- a/.cursor/rules/pr-comments.mdc +++ /dev/null @@ -1 +0,0 @@ -../../.ai/rules/pr-comments.mdc \ No newline at end of file From 053c62047c96b80ce8dfdbee79feb88fc4946e30 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 15:43:25 +0200 Subject: [PATCH 13/44] chore(mcp-server): restore registry manifest --- packages/mcp-server/server.json | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 packages/mcp-server/server.json diff --git a/packages/mcp-server/server.json b/packages/mcp-server/server.json new file mode 100644 index 00000000..affac95c --- /dev/null +++ b/packages/mcp-server/server.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.transloadit/mcp-server", + "title": "Transloadit Media Processing", + "description": "Process video, audio, images, and documents with 86+ cloud media processing robots.", + "version": "0.3.7", + "websiteUrl": "https://transloadit.com/docs/sdks/mcp-server/", + "repository": { + "url": "https://github.com/transloadit/node-sdk", + "source": "github", + "subfolder": "packages/mcp-server" + }, + "packages": [ + { + "registryType": "npm", + "identifier": "@transloadit/mcp-server", + "version": "0.3.6", + "runtimeHint": "npx", + "packageArguments": [ + { + "type": "positional", + "value": "stdio", + "description": "Transport mode for the MCP server" + } + ], + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "TRANSLOADIT_KEY", + "description": "Your Transloadit Auth Key from https://transloadit.com/c/-/api-credentials", + "isRequired": true, + "isSecret": false + }, + { + "name": "TRANSLOADIT_SECRET", + "description": "Your Transloadit Auth Secret from https://transloadit.com/c/-/api-credentials", + "isRequired": true, + "isSecret": true + } + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://api2.transloadit.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token obtained via the authenticate tool, or set TRANSLOADIT_KEY and TRANSLOADIT_SECRET env vars with the self-hosted package instead", + "isRequired": false, + "isSecret": true + } + ] + } + ] +} From 80379921ec9eecea42cf2f1a103fe12cd38c03f9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 16:10:28 +0200 Subject: [PATCH 14/44] refactor(node): streamline intent command generation --- .../node/scripts/generate-intent-commands.ts | 566 +--- packages/node/scripts/test-intents-e2e.sh | 159 +- packages/node/src/cli/commands/assemblies.ts | 596 +++-- .../src/cli/commands/generated-intents.ts | 2289 +++++------------ packages/node/src/cli/intentCommandSpecs.ts | 291 ++- packages/node/src/cli/intentRuntime.ts | 289 +++ packages/node/src/cli/intentSmokeCases.ts | 106 + packages/node/test/unit/cli/intents.test.ts | 60 +- 8 files changed, 1842 insertions(+), 2514 deletions(-) create mode 100644 packages/node/src/cli/intentSmokeCases.ts diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index b41a2644..2fcc9e7e 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -17,16 +17,15 @@ import { } from 'zod' import type { - IntentCatalogEntry, + IntentDefinition, IntentInputMode, IntentOutputMode, - RobotIntentCatalogEntry, RobotIntentDefinition, } from '../src/cli/intentCommandSpecs.ts' import { + getIntentPaths, + getIntentResultStepName, intentCatalog, - intentRecipeDefinitions, - robotIntentDefinitions, } from '../src/cli/intentCommandSpecs.ts' type GeneratedFieldKind = 'auto' | 'boolean' | 'number' | 'string' @@ -57,15 +56,7 @@ interface ResolvedIntentNoneInput { kind: 'none' } -interface ResolvedIntentRemoteUrlInput { - description: string - kind: 'remote-url' -} - -type ResolvedIntentInput = - | ResolvedIntentLocalFilesInput - | ResolvedIntentNoneInput - | ResolvedIntentRemoteUrlInput +type ResolvedIntentInput = ResolvedIntentLocalFilesInput | ResolvedIntentNoneInput interface ResolvedIntentSchemaSpec { importName: string @@ -84,17 +75,7 @@ interface ResolvedIntentTemplateExecution { templateId: string } -interface ResolvedIntentRemotePreviewExecution { - fixedValues: Record - importStepName: string - kind: 'remote-preview' - previewStepName: string -} - -type ResolvedIntentExecution = - | ResolvedIntentRemotePreviewExecution - | ResolvedIntentSingleStepExecution - | ResolvedIntentTemplateExecution +type ResolvedIntentExecution = ResolvedIntentSingleStepExecution | ResolvedIntentTemplateExecution interface ResolvedIntentCommandSpec { className: string @@ -123,27 +104,6 @@ const hiddenFieldNames = new Set([ 'use', ]) -const pathAliases = new Map([ - ['autorotate', 'auto-rotate'], - ['bgremove', 'remove-background'], -]) - -const resultStepNameAliases = new Map([ - ['/audio/waveform', 'waveformed'], - ['/document/autorotate', 'autorotated'], - ['/document/convert', 'converted'], - ['/document/optimize', 'optimized'], - ['/document/thumbs', 'thumbnailed'], - ['/file/compress', 'compressed'], - ['/file/decompress', 'decompressed'], - ['/image/bgremove', 'removed_background'], - ['/image/generate', 'generated_image'], - ['/image/optimize', 'optimized'], - ['/image/resize', 'resized'], - ['/text/speak', 'synthesized'], - ['/video/thumbs', 'thumbnailed'], -]) - const __dirname = path.dirname(fileURLToPath(import.meta.url)) const packageRoot = path.resolve(__dirname, '..') const outputPath = path.resolve(__dirname, '../src/cli/commands/generated-intents.ts') @@ -234,26 +194,13 @@ function getFieldKind(schema: unknown): GeneratedFieldKind { throw new Error('Unsupported schema type') } -function inferCommandPathsFromRobot(robot: string): string[] { - const segments = robot.split('/').filter(Boolean) - const [group, action] = segments - if (group == null || action == null) { - throw new Error(`Could not infer command path from robot "${robot}"`) - } - - return [group, pathAliases.get(action) ?? action] -} - function inferClassName(paths: string[]): string { return `${toPascalCase(paths)}Command` } -function inferInputMode( - entry: RobotIntentCatalogEntry, - definition: RobotIntentDefinition, -): Exclude { - if (entry.inputMode != null) { - return entry.inputMode +function inferInputMode(definition: RobotIntentDefinition): IntentInputMode { + if (definition.inputMode != null) { + return definition.inputMode } const shape = (definition.schema as ZodObject>).shape @@ -266,8 +213,8 @@ function inferInputMode( return 'local-files' } -function inferOutputMode(entry: IntentCatalogEntry): IntentOutputMode { - return entry.outputMode ?? 'file' +function inferOutputMode(definition: IntentDefinition): IntentOutputMode { + return definition.outputMode ?? 'file' } function inferDescription(definition: RobotIntentDefinition): string { @@ -339,11 +286,8 @@ function inferLocalFilesInput({ } } -function inferInputSpec( - entry: RobotIntentCatalogEntry, - definition: RobotIntentDefinition, -): ResolvedIntentInput { - const inputMode = inferInputMode(entry, definition) +function inferInputSpec(definition: RobotIntentDefinition): ResolvedIntentInput { + const inputMode = inferInputMode(definition) if (inputMode === 'none') { return { kind: 'none' } } @@ -353,20 +297,19 @@ function inferInputSpec( 'prompt' in shape && !unwrapSchema(shape.prompt).required ? 'prompt' : undefined return inferLocalFilesInput({ - defaultSingleAssembly: entry.defaultSingleAssembly, + defaultSingleAssembly: definition.defaultSingleAssembly, requiredFieldForInputless, }) } function inferFixedValues( - entry: RobotIntentCatalogEntry, definition: RobotIntentDefinition, - inputMode: Exclude, + inputMode: IntentInputMode, ): Record { const shape = (definition.schema as ZodObject>).shape const promptIsOptional = 'prompt' in shape && !unwrapSchema(shape.prompt).required - if (entry.defaultSingleAssembly) { + if (definition.defaultSingleAssembly) { return { robot: definition.robot, result: true, @@ -399,7 +342,19 @@ function inferFixedValues( } function inferResultStepName(robot: string): string { - return resultStepNameAliases.get(robot) ?? inferCommandPathsFromRobot(robot)[1] + const definition = intentCatalog.find( + (intent): intent is RobotIntentDefinition => intent.kind === 'robot' && intent.robot === robot, + ) + if (definition == null) { + throw new Error(`No intent definition found for "${robot}"`) + } + + const stepName = getIntentResultStepName(definition) + if (stepName == null) { + throw new Error(`Could not infer result step name for "${robot}"`) + } + + return stepName } function guessInputFile(meta: RobotMetaInput): string { @@ -475,10 +430,6 @@ function inferExamples( parts.push('--prompt', JSON.stringify(guessPromptExample(definition.robot))) } - if (inputMode === 'remote-url') { - parts.push('--input', 'https://example.com/file.pdf') - } - if (definition != null) { for (const fieldSpec of fieldSpecs) { if (!fieldSpec.required) continue @@ -552,16 +503,11 @@ function collectSchemaFields( }) } -function resolveRobotIntentSpec(entry: RobotIntentCatalogEntry): ResolvedIntentCommandSpec { - const definition = robotIntentDefinitions[entry.robot] - if (definition == null) { - throw new Error(`No robot intent definition found for "${entry.robot}"`) - } - - const paths = entry.paths ?? inferCommandPathsFromRobot(definition.robot) - const inputMode = inferInputMode(entry, definition) - const outputMode = inferOutputMode(entry) - const input = inferInputSpec(entry, definition) +function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { + const paths = getIntentPaths(definition) + const inputMode = inferInputMode(definition) + const outputMode = inferOutputMode(definition) + const input = inferInputSpec(definition) const schemaSpec = { importName: definition.schemaImportName, importPath: definition.schemaImportPath, @@ -570,14 +516,19 @@ function resolveRobotIntentSpec(entry: RobotIntentCatalogEntry): ResolvedIntentC const execution = { kind: 'single-step', resultStepName: inferResultStepName(definition.robot), - fixedValues: inferFixedValues(entry, definition, inputMode), + fixedValues: inferFixedValues(definition, inputMode), } satisfies ResolvedIntentSingleStepExecution const fieldSpecs = collectSchemaFields(schemaSpec, execution.fixedValues, input) return { className: inferClassName(paths), description: inferDescription(definition), - details: inferDetails(definition, inputMode, outputMode, entry.defaultSingleAssembly === true), + details: inferDetails( + definition, + inputMode, + outputMode, + definition.defaultSingleAssembly === true, + ), examples: inferExamples(definition, paths, inputMode, outputMode, fieldSpecs), input, outputDescription: inferOutputDescription(inputMode, outputMode), @@ -590,77 +541,37 @@ function resolveRobotIntentSpec(entry: RobotIntentCatalogEntry): ResolvedIntentC } function resolveTemplateIntentSpec( - entry: IntentCatalogEntry & { kind: 'template' }, + definition: IntentDefinition & { kind: 'template' }, ): ResolvedIntentCommandSpec { - const outputMode = inferOutputMode(entry) + const outputMode = inferOutputMode(definition) const input = inferLocalFilesInput({}) + const paths = getIntentPaths(definition) return { - className: inferClassName(entry.paths), - description: `Run ${stripTrailingPunctuation(entry.templateId)}`, - details: `Runs the \`${entry.templateId}\` template and writes the outputs to \`--out\`.`, + className: inferClassName(paths), + description: `Run ${stripTrailingPunctuation(definition.templateId)}`, + details: `Runs the \`${definition.templateId}\` template and writes the outputs to \`--out\`.`, examples: [ - ['Run the command', `transloadit ${entry.paths.join(' ')} --input input.mp4 --out output/`], + ['Run the command', `transloadit ${paths.join(' ')} --input input.mp4 --out output/`], ], execution: { kind: 'template', - templateId: entry.templateId, + templateId: definition.templateId, }, input, outputDescription: inferOutputDescription('local-files', outputMode), outputMode, outputRequired: true, - paths: entry.paths, - } -} - -function resolveRecipeIntentSpec( - entry: IntentCatalogEntry & { kind: 'recipe' }, -): ResolvedIntentCommandSpec { - const definition = intentRecipeDefinitions[entry.recipe] - if (definition == null) { - throw new Error(`No intent recipe definition found for "${entry.recipe}"`) - } - - return { - className: inferClassName(definition.paths), - description: definition.description, - details: definition.details, - examples: definition.examples, - execution: { - kind: 'remote-preview', - importStepName: 'imported', - previewStepName: definition.resultStepName, - fixedValues: { - robot: '/file/preview', - result: true, - }, - }, - input: { - kind: 'remote-url', - description: 'Remote URL to preview', - }, - outputDescription: definition.outputDescription, - outputRequired: definition.outputRequired, - paths: definition.paths, - schemaSpec: { - importName: definition.schemaImportName, - importPath: definition.schemaImportPath, - schema: definition.schema as ZodObject>, - }, + paths, } } -function resolveIntentCommandSpec(entry: IntentCatalogEntry): ResolvedIntentCommandSpec { - if (entry.kind === 'robot') { - return resolveRobotIntentSpec(entry) +function resolveIntentCommandSpec(definition: IntentDefinition): ResolvedIntentCommandSpec { + if (definition.kind === 'robot') { + return resolveRobotIntentSpec(definition) } - if (entry.kind === 'template') { - return resolveTemplateIntentSpec(entry) - } - - return resolveRecipeIntentSpec(entry) + return resolveTemplateIntentSpec(definition) } function formatDescription(description: string | undefined): string { @@ -707,313 +618,91 @@ ${fieldSpecs ]` } -function formatLocalInputOptions(input: ResolvedIntentLocalFilesInput): string { - const blocks = [ - ` inputs = Option.Array('--input,-i', { - description: ${JSON.stringify(input.description)}, - })`, - ` inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - })`, - ] - - if (input.recursive !== false) { - blocks.push(` recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - })`) - } - - if (input.allowWatch) { - blocks.push(` watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - })`) - } - - if (input.deleteAfterProcessing !== false) { - blocks.push(` deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - })`) - } - - if (input.reprocessStale !== false) { - blocks.push(` reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - })`) - } - - if (input.allowSingleAssembly) { - blocks.push(` singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - })`) - } - - if (input.allowConcurrency) { - blocks.push(` concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - })`) - } - - return blocks.join('\n\n') -} - -function formatInputOptions(spec: ResolvedIntentCommandSpec): string { - if (spec.input.kind === 'local-files') { - return formatLocalInputOptions(spec.input) - } - - if (spec.input.kind === 'remote-url') { - return ` input = Option.String('--input,-i', { - description: ${JSON.stringify(spec.input.description)}, - required: true, - })` +function resolveFixedValues(spec: ResolvedIntentCommandSpec): Record { + if (spec.execution.kind === 'single-step') { + return spec.execution.fixedValues } - return '' + return {} } -function formatLocalCreateOptions(spec: ResolvedIntentCommandSpec): string { - if (spec.input.kind !== 'local-files') { - throw new Error('Expected a local-files input spec') - } - - const entries = [' inputs: preparedInputs.inputs,', ' output: this.outputPath,'] - - if (spec.outputMode != null) { - entries.push(` outputMode: ${JSON.stringify(spec.outputMode)},`) - } - - if (spec.input.recursive !== false) { - entries.push(' recursive: this.recursive,') - } - - if (spec.input.allowWatch) { - entries.push(' watch: this.watch,') - } - - if (spec.input.deleteAfterProcessing !== false) { - entries.push(' del: this.deleteAfterProcessing,') - } - - if (spec.input.reprocessStale !== false) { - entries.push(' reprocessStale: this.reprocessStale,') - } - - if (spec.input.allowSingleAssembly) { - entries.push(' singleAssembly: this.singleAssembly,') - } else if (spec.input.defaultSingleAssembly) { - entries.push(' singleAssembly: true,') - } +function generateImports(specs: ResolvedIntentCommandSpec[]): string { + const imports = new Map() - if (spec.input.allowConcurrency) { - entries.push( - ' concurrency: this.concurrency == null ? undefined : Number(this.concurrency),', - ) + for (const spec of specs) { + if (spec.schemaSpec == null) continue + imports.set(spec.schemaSpec.importName, spec.schemaSpec.importPath) } - return entries.join('\n') + return [...imports.entries()] + .sort(([nameA], [nameB]) => nameA.localeCompare(nameB)) + .map(([importName, importPath]) => `import { ${importName} } from '${importPath}'`) + .join('\n') } -function formatLocalValidation(spec: ResolvedIntentCommandSpec, commandLabel: string): string { - if (spec.input.kind !== 'local-files') { - throw new Error('Expected a local-files input spec') - } - - const lines = - spec.input.requiredFieldForInputless == null - ? [ - ' if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) {', - ` this.output.error('${commandLabel} requires --input or --input-base64')`, - ' return 1', - ' }', - ] - : [ - ` if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0 && this.${toCamelCase(spec.input.requiredFieldForInputless)} == null) {`, - ` this.output.error('${commandLabel} requires --input or --${toKebabCase(spec.input.requiredFieldForInputless)}')`, - ' return 1', - ' }', - ] - - if (spec.input.allowWatch && spec.input.allowSingleAssembly) { - lines.push( - '', - ' if (this.singleAssembly && this.watch) {', - " this.output.error('--single-assembly cannot be used with --watch')", - ' return 1', - ' }', - ) - } - - if (spec.input.allowWatch && spec.input.defaultSingleAssembly) { - lines.push( - '', - ' if (this.watch) {', - " this.output.error('--watch is not supported for this command')", - ' return 1', - ' }', - ) - } - - return lines.join('\n') +function getCommandDefinitionName(spec: ResolvedIntentCommandSpec): string { + return `${spec.className[0]?.toLowerCase() ?? ''}${spec.className.slice(1)}Definition` } -function formatSingleStepFixedValues(spec: ResolvedIntentCommandSpec): string { - if (spec.execution.kind !== 'single-step') { - throw new Error('Expected a single-step execution spec') +function getBaseClassName(spec: ResolvedIntentCommandSpec): string { + if (spec.input.kind === 'none') { + return 'GeneratedNoInputIntentCommand' } - if (spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null) { - const baseFixedValues = JSON.stringify(spec.execution.fixedValues, null, 6).replace( - /\n/g, - '\n ', - ) - - return `(this.inputs ?? []).length > 0 - ? { - ...${baseFixedValues}, - use: ':original', - } - : ${baseFixedValues}` + if (spec.input.defaultSingleAssembly) { + return 'GeneratedBundledFileIntentCommand' } - return JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ') + return 'GeneratedStandardFileIntentCommand' } -function formatRunBody( - spec: ResolvedIntentCommandSpec, - fieldSpecs: GeneratedSchemaField[], -): string { - const schemaSpec = spec.schemaSpec - const transientWatchGuard = - spec.input.kind === 'local-files' && spec.input.allowWatch - ? ` - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - }` - : '' - - if (spec.execution.kind === 'single-step') { - const parseStep = ` const step = parseIntentStep({ - schema: ${schemaSpec?.importName}, - fixedValues: ${formatSingleStepFixedValues(spec)}, - fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, - rawValues: ${formatRawValues(fieldSpecs)}, - })` - - if (spec.input.kind === 'local-files') { - return `${formatLocalValidation(spec, spec.paths.join(' '))} - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - })${transientWatchGuard} - - try { -${parseStep} - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - ${JSON.stringify(spec.execution.resultStepName)}: step, - }, -${formatLocalCreateOptions(spec)} - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) - }` - } - - return `${parseStep} - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - ${JSON.stringify(spec.execution.resultStepName)}: step, - }, - inputs: [], - output: this.outputPath, - }) - - return hasFailures ? 1 : undefined` - } - - if (spec.execution.kind === 'remote-preview') { - const parseStep = ` const previewStep = parseIntentStep({ - schema: ${schemaSpec?.importName}, - fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 6).replace(/\n/g, '\n ')}, - fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, - rawValues: ${formatRawValues(fieldSpecs)}, - })` - - return `${parseStep} - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - ${JSON.stringify(spec.execution.importStepName)}: { - robot: '/http/import', - url: this.input, - }, - ${JSON.stringify(spec.execution.previewStepName)}: { - ...previewStep, - use: ${JSON.stringify(spec.execution.importStepName)}, - }, - }, - inputs: [], - output: this.outputPath, - }) - - return hasFailures ? 1 : undefined` - } - - if (spec.input.kind !== 'local-files') { - throw new Error(`Template command ${spec.className} requires local-files input`) - } - - return `${formatLocalValidation(spec, spec.paths.join(' '))} - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - })${transientWatchGuard} - - try { - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - template: ${JSON.stringify(spec.execution.templateId)}, -${formatLocalCreateOptions(spec)} - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) - }` -} +function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { + const fieldSpecs = + spec.schemaSpec == null + ? [] + : collectSchemaFields(spec.schemaSpec, resolveFixedValues(spec), spec.input) + const commandLabel = spec.paths.join(' ') -function resolveFixedValues(spec: ResolvedIntentCommandSpec): Record { if (spec.execution.kind === 'single-step') { - return spec.execution.fixedValues - } - - if (spec.execution.kind === 'remote-preview') { - return spec.execution.fixedValues + const attachUseWhenInputsProvided = + spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null + ? '\n attachUseWhenInputsProvided: true,' + : '' + const commandLabelLine = + spec.input.kind === 'local-files' ? `\n commandLabel: ${JSON.stringify(commandLabel)},` : '' + const requiredField = + spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null + ? `\n requiredFieldForInputless: ${JSON.stringify(spec.input.requiredFieldForInputless)},` + : '' + const outputMode = + spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` + + return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${requiredField}${outputMode} + execution: { + kind: 'single-step', + schema: ${spec.schemaSpec?.importName}, + fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, + fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 4).replace(/\n/g, '\n ')}, + resultStepName: ${JSON.stringify(spec.execution.resultStepName)},${attachUseWhenInputsProvided} + }, +} as const` } - return {} + const outputMode = + spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` + return `const ${getCommandDefinitionName(spec)} = { + commandLabel: ${JSON.stringify(commandLabel)},${outputMode} + execution: { + kind: 'template', + templateId: ${JSON.stringify(spec.execution.templateId)}, + }, +} as const` } -function generateImports(specs: ResolvedIntentCommandSpec[]): string { - const imports = new Map() - - for (const spec of specs) { - if (spec.schemaSpec == null) continue - imports.set(spec.schemaSpec.importName, spec.schemaSpec.importPath) - } - - return [...imports.entries()] - .sort(([nameA], [nameB]) => nameA.localeCompare(nameB)) - .map(([importName, importPath]) => `import { ${importName} } from '${importPath}'`) - .join('\n') +function formatRawValuesMethod(fieldSpecs: GeneratedSchemaField[]): string { + return ` protected override getIntentRawValues(): Record { + return ${formatRawValues(fieldSpecs)} + }` } function generateClass(spec: ResolvedIntentCommandSpec): string { @@ -1021,11 +710,11 @@ function generateClass(spec: ResolvedIntentCommandSpec): string { const fieldSpecs = spec.schemaSpec == null ? [] : collectSchemaFields(spec.schemaSpec, fixedValues, spec.input) const schemaFields = formatSchemaFields(fieldSpecs) - const inputOptions = formatInputOptions(spec) - const runBody = formatRunBody(spec, fieldSpecs) + const rawValuesMethod = formatRawValuesMethod(fieldSpecs) + const baseClassName = getBaseClassName(spec) return ` -class ${spec.className} extends AuthenticatedCommand { +class ${spec.className} extends ${baseClassName} { static override paths = ${JSON.stringify([spec.paths])} static override usage = Command.Usage({ @@ -1037,21 +726,20 @@ ${formatUsageExamples(spec.examples)} ], }) -${schemaFields}${schemaFields && inputOptions ? '\n\n' : ''}${inputOptions} + protected override readonly intentDefinition = ${getCommandDefinitionName(spec)} - outputPath = Option.String('--out,-o', { + override outputPath = Option.String('--out,-o', { description: ${JSON.stringify(spec.outputDescription)}, required: ${spec.outputRequired}, }) - protected async run(): Promise { -${runBody} - } +${schemaFields}${schemaFields ? '\n\n' : ''}${rawValuesMethod} } ` } function generateFile(specs: ResolvedIntentCommandSpec[]): string { + const commandDefinitions = specs.map(formatIntentDefinition) const commandClasses = specs.map(generateClass) const commandNames = specs.map((spec) => spec.className) @@ -1059,12 +747,14 @@ function generateFile(specs: ResolvedIntentCommandSpec[]): string { // Generated by \`packages/node/scripts/generate-intent-commands.ts\`. import { Command, Option } from 'clipanion' -import * as t from 'typanion' ${generateImports(specs)} -import { parseIntentStep, prepareIntentInputs } from '../intentRuntime.ts' -import * as assembliesCommands from './assemblies.ts' -import { AuthenticatedCommand } from './BaseCommand.ts' +import { + GeneratedBundledFileIntentCommand, + GeneratedNoInputIntentCommand, + GeneratedStandardFileIntentCommand, +} from '../intentRuntime.ts' +${commandDefinitions.join('\n\n')} ${commandClasses.join('\n')} export const intentCommands = [ ${commandNames.map((name) => ` ${name},`).join('\n')} diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index c6e71e27..5cba9b15 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -97,6 +97,37 @@ verify_file_decompress() { grep -F 'Hello from Transloadit CLI intents' "$1/input.txt" >/dev/null } +verify_output() { + local verifier="$1" + local path="$2" + + case "$verifier" in + png) verify_png "$path" ;; + jpeg) verify_jpeg "$path" ;; + pdf) verify_pdf "$path" ;; + mp3) verify_mp3 "$path" ;; + zip) verify_zip "$path" ;; + document-thumbs) verify_document_thumbs "$path" ;; + video-thumbs) verify_video_thumbs "$path" ;; + video-encode-hls) verify_video_encode_hls "$path" ;; + file-decompress) verify_file_decompress "$path" ;; + *) + echo "Unknown verifier: $verifier" >&2 + return 1 + ;; + esac +} + +resolve_placeholder() { + local arg="$1" + + case "$arg" in + @preview-url) printf '%s\n' "$PREVIEW_URL" ;; + @fixture/*) printf '%s\n' "$FIXTUREDIR/${arg#@fixture/}" ;; + *) printf '%s\n' "$arg" ;; + esac +} + run_case() { local name="$1" local output_path="$2" @@ -115,7 +146,7 @@ run_case() { local verdict='FAIL' local detail='' - if [[ $exit_code -eq 0 ]] && "$verifier" "$output_path"; then + if [[ $exit_code -eq 0 ]] && verify_output "$verifier" "$output_path"; then verdict='OK' if [[ -f "$output_path" ]]; then detail="$(file "$output_path" | sed 's#^.*: ##' | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g')" @@ -138,101 +169,37 @@ prepare_fixtures RESULTS_TSV="$WORKDIR/results.tsv" printf 'command\texit\tverdict\tdetail\n' >"$RESULTS_TSV" -run_case image-generate "$OUTDIR/image-generate.png" verify_png \ - image generate \ - --prompt 'A small red bicycle on a cream background, studio lighting' \ - --model 'google/nano-banana' \ - --out "$OUTDIR/image-generate.png" \ - >>"$RESULTS_TSV" - -run_case preview-generate "$OUTDIR/preview-generate.png" verify_png \ - preview generate \ - --input "$PREVIEW_URL" \ - --width 300 \ - --out "$OUTDIR/preview-generate.png" \ - >>"$RESULTS_TSV" - -run_case image-remove-background "$OUTDIR/image-remove-background.png" verify_png \ - image remove-background \ - --input "$FIXTUREDIR/input.jpg" \ - --out "$OUTDIR/image-remove-background.png" \ - >>"$RESULTS_TSV" - -run_case image-optimize "$OUTDIR/image-optimize.jpg" verify_jpeg \ - image optimize \ - --input "$FIXTUREDIR/input.jpg" \ - --out "$OUTDIR/image-optimize.jpg" \ - >>"$RESULTS_TSV" - -run_case image-resize "$OUTDIR/image-resize.jpg" verify_jpeg \ - image resize \ - --input "$FIXTUREDIR/input.jpg" \ - --width 200 \ - --out "$OUTDIR/image-resize.jpg" \ - >>"$RESULTS_TSV" - -run_case document-convert "$OUTDIR/document-convert.pdf" verify_pdf \ - document convert \ - --input "$FIXTUREDIR/input.txt" \ - --format pdf \ - --out "$OUTDIR/document-convert.pdf" \ - >>"$RESULTS_TSV" - -run_case document-optimize "$OUTDIR/document-optimize.pdf" verify_pdf \ - document optimize \ - --input "$FIXTUREDIR/input.pdf" \ - --out "$OUTDIR/document-optimize.pdf" \ - >>"$RESULTS_TSV" - -run_case document-auto-rotate "$OUTDIR/document-auto-rotate.pdf" verify_pdf \ - document auto-rotate \ - --input "$FIXTUREDIR/input.pdf" \ - --out "$OUTDIR/document-auto-rotate.pdf" \ - >>"$RESULTS_TSV" - -run_case document-thumbs "$OUTDIR/document-thumbs" verify_document_thumbs \ - document thumbs \ - --input "$FIXTUREDIR/input.pdf" \ - --out "$OUTDIR/document-thumbs" \ - >>"$RESULTS_TSV" - -run_case audio-waveform "$OUTDIR/audio-waveform.png" verify_png \ - audio waveform \ - --input "$FIXTUREDIR/input.mp3" \ - --out "$OUTDIR/audio-waveform.png" \ - >>"$RESULTS_TSV" - -run_case text-speak "$OUTDIR/text-speak.mp3" verify_mp3 \ - text speak \ - --prompt 'Hello from the Transloadit Node CLI intents test.' \ - --provider aws \ - --out "$OUTDIR/text-speak.mp3" \ - >>"$RESULTS_TSV" - -run_case video-thumbs "$OUTDIR/video-thumbs" verify_video_thumbs \ - video thumbs \ - --input "$FIXTUREDIR/input.mp4" \ - --out "$OUTDIR/video-thumbs" \ - >>"$RESULTS_TSV" - -run_case video-encode-hls "$OUTDIR/video-encode-hls" verify_video_encode_hls \ - video encode-hls \ - --input "$FIXTUREDIR/input.mp4" \ - --out "$OUTDIR/video-encode-hls" \ - >>"$RESULTS_TSV" - -run_case file-compress "$OUTDIR/file-compress.zip" verify_zip \ - file compress \ - --input "$FIXTUREDIR/input.txt" \ - --format zip \ - --out "$OUTDIR/file-compress.zip" \ - >>"$RESULTS_TSV" - -run_case file-decompress "$OUTDIR/file-decompress" verify_file_decompress \ - file decompress \ - --input "$FIXTUREDIR/input.zip" \ - --out "$OUTDIR/file-decompress" \ - >>"$RESULTS_TSV" +while IFS=$'\t' read -r name path_string args_string output_rel verifier; do + [[ -n "$name" ]] || continue + + read -r -a path_parts <<<"$path_string" + IFS=$'\x1f' read -r -a raw_args <<<"$args_string" + + resolved_args=() + for arg in "${raw_args[@]}"; do + resolved_args+=("$(resolve_placeholder "$arg")") + done + + run_case "$name" "$OUTDIR/$output_rel" "$verifier" \ + "${path_parts[@]}" \ + "${resolved_args[@]}" \ + --out "$OUTDIR/$output_rel" \ + >>"$RESULTS_TSV" +done < <( + node --input-type=module <<'NODE' +import { intentSmokeCases } from './packages/node/src/cli/intentSmokeCases.ts' + +for (const smokeCase of intentSmokeCases) { + console.log([ + smokeCase.paths.join('-'), + smokeCase.paths.join(' '), + smokeCase.args.join('\x1f'), + smokeCase.outputPath, + smokeCase.verifier, + ].join('\t')) +} +NODE +) column -t -s $'\t' "$RESULTS_TSV" diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 39d59af0..c9c431c0 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -5,7 +5,6 @@ import fsp from 'node:fs/promises' import path from 'node:path' import process from 'node:process' import type { Readable } from 'node:stream' -import { Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { setTimeout as delay } from 'node:timers/promises' import tty from 'node:tty' @@ -300,51 +299,52 @@ async function getNodeWatch(): Promise { const stdinWithPath = process.stdin as unknown as { path: string } stdinWithPath.path = '/dev/stdin' -interface OutStream extends Writable { +interface OutputPlan { + kind: 'file' | 'stdout' + mtime: Date path?: string - mtime?: Date } interface Job { in: Readable | null - out: OutStream | null + out: OutputPlan | null } -type OutstreamProvider = (inpath: string | null, indir?: string) => Promise +type OutputPlanProvider = (inpath: string | null, indir?: string) => Promise -interface StreamRegistry { - [key: string]: OutStream | undefined +interface OutputPlanRegistry { + [key: string]: OutputPlan | undefined } interface JobEmitterOptions { allowOutputCollisions?: boolean recursive?: boolean - outstreamProvider: OutstreamProvider + outputPlanProvider: OutputPlanProvider singleAssembly?: boolean - streamRegistry: StreamRegistry + outputPlanRegistry: OutputPlanRegistry watch?: boolean reprocessStale?: boolean } interface ReaddirJobEmitterOptions { dir: string - streamRegistry: StreamRegistry + outputPlanRegistry: OutputPlanRegistry recursive?: boolean - outstreamProvider: OutstreamProvider + outputPlanProvider: OutputPlanProvider topdir?: string } interface SingleJobEmitterOptions { file: string - streamRegistry: StreamRegistry - outstreamProvider: OutstreamProvider + outputPlanRegistry: OutputPlanRegistry + outputPlanProvider: OutputPlanProvider } interface WatchJobEmitterOptions { file: string - streamRegistry: StreamRegistry + outputPlanRegistry: OutputPlanRegistry recursive?: boolean - outstreamProvider: OutstreamProvider + outputPlanProvider: OutputPlanProvider } interface StatLike { @@ -364,19 +364,22 @@ async function myStat( return await fsp.stat(filepath) } -function createPlaceholderOutStream(outpath: string, mtime: Date): OutStream { - const outstream = new Writable({ - write(_chunk, _encoding, callback) { - callback() - }, - }) as OutStream - outstream.path = outpath - outstream.mtime = mtime - outstream.on('error', () => {}) - return outstream +function createOutputPlan(pathname: string | undefined, mtime: Date): OutputPlan { + if (pathname == null) { + return { + kind: 'stdout', + mtime, + } + } + + return { + kind: 'file', + mtime, + path: pathname, + } } -function dirProvider(output: string): OutstreamProvider { +function dirProvider(output: string): OutputPlanProvider { return async (inpath, indir = process.cwd()) => { // Inputless assemblies can still write into a directory, but output paths are derived from // assembly results rather than an input file path (handled later). @@ -392,21 +395,23 @@ function dirProvider(output: string): OutstreamProvider { const outpath = path.join(output, relpath) const [, stats] = await tryCatch(fsp.stat(outpath)) const mtime = stats?.mtime ?? new Date(0) - return createPlaceholderOutStream(outpath, mtime) + return createOutputPlan(outpath, mtime) } } -function fileProvider(output: string): OutstreamProvider { +function fileProvider(output: string): OutputPlanProvider { return async (_inpath) => { - if (output === '-') return process.stdout as OutStream + if (output === '-') { + return createOutputPlan(undefined, new Date(0)) + } const [, stats] = await tryCatch(fsp.stat(output)) const mtime = stats?.mtime ?? new Date(0) - return createPlaceholderOutStream(output, mtime) + return createOutputPlan(output, mtime) } } -function nullProvider(): OutstreamProvider { +function nullProvider(): OutputPlanProvider { return async (_inpath) => null } @@ -421,7 +426,7 @@ async function downloadResultToFile( path.dirname(outPath), `.${path.basename(outPath)}.${randomUUID()}.tmp`, ) - const outStream = fs.createWriteStream(tempPath) as OutStream + const outStream = fs.createWriteStream(tempPath) outStream.on('error', () => {}) const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal }), outStream)) @@ -433,6 +438,204 @@ async function downloadResultToFile( await fsp.rename(tempPath, outPath) } +interface AssemblyResultFile { + file: { + basename?: string | null + ext?: string | null + name?: string | null + ssl_url?: string | null + url?: string | null + } + stepName: string +} + +function getResultFileUrl(file: AssemblyResultFile['file']): string | null { + return file.ssl_url ?? file.url ?? null +} + +function sanitizeResultName(value: string): string { + const base = path.basename(value) + return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '') +} + +async function ensureUniquePath(targetPath: string): Promise { + const parsed = path.parse(targetPath) + let candidate = targetPath + let counter = 1 + while (true) { + const [statErr] = await tryCatch(fsp.stat(candidate)) + if (statErr) { + return candidate + } + candidate = path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`) + counter += 1 + } +} + +function flattenAssemblyResults(results: Record>): { + allFiles: AssemblyResultFile[] + entries: Array<[string, Array]> +} { + const entries = Object.entries(results) + const allFiles: AssemblyResultFile[] = [] + for (const [stepName, stepResults] of entries) { + for (const file of stepResults) { + allFiles.push({ stepName, file }) + } + } + + return { allFiles, entries } +} + +async function materializeAssemblyResults({ + abortSignal, + hasDirectoryInput, + inPath, + inputs, + outputMode, + outputPath, + outputRoot, + outputRootIsDirectory, + outputctl, + results, + singleAssembly, +}: { + abortSignal: AbortSignal + hasDirectoryInput: boolean + inPath: string | null + inputs: string[] + outputMode?: 'directory' | 'file' + outputPath: string | null + outputRoot: string | null + outputRootIsDirectory: boolean + outputctl: IOutputCtl + results: Record> + singleAssembly?: boolean +}): Promise { + if (outputRoot == null) { + return + } + + const { allFiles, entries } = flattenAssemblyResults(results) + const shouldGroupByInput = + !singleAssembly && inPath != null && (hasDirectoryInput || inputs.length > 1) + const useIntentDirectoryLayout = outputMode === 'directory' + + const downloadResultFile = async (resultUrl: string, targetPath: string): Promise => { + outputctl.debug('DOWNLOADING') + const [dlErr] = await tryCatch(downloadResultToFile(resultUrl, targetPath, abortSignal)) + if (dlErr) { + if (dlErr.name === 'AbortError') { + return + } + outputctl.error(dlErr.message) + throw dlErr + } + } + + const resolveDirectoryBaseDir = (): string => { + if (!shouldGroupByInput || inPath == null) { + return outputRoot + } + + if (hasDirectoryInput && outputPath != null) { + const mappedRelative = path.relative(outputRoot, outputPath) + const mappedDir = path.dirname(mappedRelative) + const mappedStem = path.parse(mappedRelative).name + return path.join(outputRoot, mappedDir === '.' ? '' : mappedDir, mappedStem) + } + + return path.join(outputRoot, path.parse(path.basename(inPath)).name) + } + + if (!outputRootIsDirectory) { + if (outputPath == null) { + return + } + + const first = allFiles[0] + const resultUrl = first == null ? null : getResultFileUrl(first.file) + if (resultUrl != null) { + await downloadResultFile(resultUrl, outputPath) + } + return + } + + if (singleAssembly) { + await fsp.mkdir(outputRoot, { recursive: true }) + for (const { stepName, file } of allFiles) { + const resultUrl = getResultFileUrl(file) + if (resultUrl == null) { + continue + } + + const rawName = + file.name ?? + (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? + `${stepName}_result` + const safeName = sanitizeResultName(rawName) + const targetPath = await ensureUniquePath(path.join(outputRoot, safeName)) + await downloadResultFile(resultUrl, targetPath) + } + return + } + + if (useIntentDirectoryLayout || outputPath == null) { + const baseDir = resolveDirectoryBaseDir() + await fsp.mkdir(baseDir, { recursive: true }) + const shouldUseStepDirectories = entries.length > 1 + + for (const { stepName, file } of allFiles) { + const resultUrl = getResultFileUrl(file) + if (resultUrl == null) { + continue + } + + const targetDir = shouldUseStepDirectories ? path.join(baseDir, stepName) : baseDir + await fsp.mkdir(targetDir, { recursive: true }) + + const rawName = + file.name ?? + (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? + `${stepName}_result` + const safeName = sanitizeResultName(rawName) + const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) + await downloadResultFile(resultUrl, targetPath) + } + + return + } + + if (allFiles.length === 1) { + const first = allFiles[0] + const resultUrl = first == null ? null : getResultFileUrl(first.file) + if (resultUrl != null) { + await downloadResultFile(resultUrl, outputPath) + } + return + } + + const legacyBaseDir = path.join(path.dirname(outputPath), path.parse(outputPath).name) + + for (const { stepName, file } of allFiles) { + const resultUrl = getResultFileUrl(file) + if (resultUrl == null) { + continue + } + + const targetDir = path.join(legacyBaseDir, stepName) + await fsp.mkdir(targetDir, { recursive: true }) + + const rawName = + file.name ?? + (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? + `${stepName}_result` + const safeName = sanitizeResultName(rawName) + const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) + await downloadResultFile(resultUrl, targetPath) + } +} + class MyEventEmitter extends EventEmitter { protected hasEnded: boolean @@ -454,27 +657,31 @@ class MyEventEmitter extends EventEmitter { class ReaddirJobEmitter extends MyEventEmitter { constructor({ dir, - streamRegistry, + outputPlanRegistry, recursive, - outstreamProvider, + outputPlanProvider, topdir = dir, }: ReaddirJobEmitterOptions) { super() process.nextTick(() => { - this.processDirectory({ dir, streamRegistry, recursive, outstreamProvider, topdir }).catch( - (err) => { - this.emit('error', err) - }, - ) + this.processDirectory({ + dir, + outputPlanRegistry, + recursive, + outputPlanProvider, + topdir, + }).catch((err) => { + this.emit('error', err) + }) }) } private async processDirectory({ dir, - streamRegistry, + outputPlanRegistry, recursive, - outstreamProvider, + outputPlanProvider, topdir, }: ReaddirJobEmitterOptions & { topdir: string }): Promise { const files = await fsp.readdir(dir) @@ -484,7 +691,7 @@ class ReaddirJobEmitter extends MyEventEmitter { for (const filename of files) { const file = path.normalize(path.join(dir, filename)) pendingOperations.push( - this.processFile({ file, streamRegistry, recursive, outstreamProvider, topdir }), + this.processFile({ file, outputPlanRegistry, recursive, outputPlanProvider, topdir }), ) } @@ -494,15 +701,15 @@ class ReaddirJobEmitter extends MyEventEmitter { private async processFile({ file, - streamRegistry, + outputPlanRegistry, recursive = false, - outstreamProvider, + outputPlanProvider, topdir, }: { file: string - streamRegistry: StreamRegistry + outputPlanRegistry: OutputPlanRegistry recursive?: boolean - outstreamProvider: OutstreamProvider + outputPlanProvider: OutputPlanProvider topdir: string }): Promise { const stats = await fsp.stat(file) @@ -512,9 +719,9 @@ class ReaddirJobEmitter extends MyEventEmitter { await new Promise((resolve, reject) => { const subdirEmitter = new ReaddirJobEmitter({ dir: file, - streamRegistry, + outputPlanRegistry, recursive, - outstreamProvider, + outputPlanProvider, topdir, }) subdirEmitter.on('job', (job: Job) => this.emit('job', job)) @@ -523,28 +730,24 @@ class ReaddirJobEmitter extends MyEventEmitter { }) } } else { - const existing = streamRegistry[file] - if (existing) existing.end() - const outstream = await outstreamProvider(file, topdir) - streamRegistry[file] = outstream ?? undefined + const outputPlan = await outputPlanProvider(file, topdir) + outputPlanRegistry[file] = outputPlan ?? undefined const instream = fs.createReadStream(file) // Attach a no-op error handler to prevent unhandled errors if stream is destroyed // before being consumed (e.g., due to output collision detection) instream.on('error', () => {}) - this.emit('job', { in: instream, out: outstream }) + this.emit('job', { in: instream, out: outputPlan }) } } } class SingleJobEmitter extends MyEventEmitter { - constructor({ file, streamRegistry, outstreamProvider }: SingleJobEmitterOptions) { + constructor({ file, outputPlanRegistry, outputPlanProvider }: SingleJobEmitterOptions) { super() const normalizedFile = path.normalize(file) - const existing = streamRegistry[normalizedFile] - if (existing) existing.end() - outstreamProvider(normalizedFile).then((outstream) => { - streamRegistry[normalizedFile] = outstream ?? undefined + outputPlanProvider(normalizedFile).then((outputPlan) => { + outputPlanRegistry[normalizedFile] = outputPlan ?? undefined let instream: Readable | null if (normalizedFile === '-') { @@ -561,7 +764,7 @@ class SingleJobEmitter extends MyEventEmitter { } process.nextTick(() => { - this.emit('job', { in: instream, out: outstream }) + this.emit('job', { in: instream, out: outputPlan }) this.emit('end') }) }) @@ -569,15 +772,13 @@ class SingleJobEmitter extends MyEventEmitter { } class InputlessJobEmitter extends MyEventEmitter { - constructor({ - outstreamProvider, - }: { streamRegistry: StreamRegistry; outstreamProvider: OutstreamProvider }) { + constructor({ outputPlanProvider }: { outputPlanProvider: OutputPlanProvider }) { super() process.nextTick(() => { - outstreamProvider(null).then((outstream) => { + outputPlanProvider(null).then((outputPlan) => { try { - this.emit('job', { in: null, out: outstream }) + this.emit('job', { in: null, out: outputPlan }) } catch (err) { this.emit('error', err) } @@ -598,10 +799,10 @@ class NullJobEmitter extends MyEventEmitter { class WatchJobEmitter extends MyEventEmitter { private watcher: NodeWatcher | null = null - constructor({ file, streamRegistry, recursive, outstreamProvider }: WatchJobEmitterOptions) { + constructor({ file, outputPlanRegistry, recursive, outputPlanProvider }: WatchJobEmitterOptions) { super() - this.init({ file, streamRegistry, recursive, outstreamProvider }).catch((err) => { + this.init({ file, outputPlanRegistry, recursive, outputPlanProvider }).catch((err) => { this.emit('error', err) }) @@ -621,9 +822,9 @@ class WatchJobEmitter extends MyEventEmitter { private async init({ file, - streamRegistry, + outputPlanRegistry, recursive, - outstreamProvider, + outputPlanProvider, }: WatchJobEmitterOptions): Promise { const stats = await fsp.stat(file) const topdir = stats.isDirectory() ? file : undefined @@ -638,32 +839,31 @@ class WatchJobEmitter extends MyEventEmitter { this.watcher.on('close', () => this.emit('end')) this.watcher.on('change', (_evt: string, filename: string) => { const normalizedFile = path.normalize(filename) - this.handleChange(normalizedFile, topdir, streamRegistry, outstreamProvider).catch((err) => { - this.emit('error', err) - }) + this.handleChange(normalizedFile, topdir, outputPlanRegistry, outputPlanProvider).catch( + (err) => { + this.emit('error', err) + }, + ) }) } private async handleChange( normalizedFile: string, topdir: string | undefined, - streamRegistry: StreamRegistry, - outstreamProvider: OutstreamProvider, + outputPlanRegistry: OutputPlanRegistry, + outputPlanProvider: OutputPlanProvider, ): Promise { const stats = await fsp.stat(normalizedFile) if (stats.isDirectory()) return - const existing = streamRegistry[normalizedFile] - if (existing) existing.end() - - const outstream = await outstreamProvider(normalizedFile, topdir) - streamRegistry[normalizedFile] = outstream ?? undefined + const outputPlan = await outputPlanProvider(normalizedFile, topdir) + outputPlanRegistry[normalizedFile] = outputPlan ?? undefined const instream = fs.createReadStream(normalizedFile) // Attach a no-op error handler to prevent unhandled errors if stream is destroyed // before being consumed (e.g., due to output collision detection) instream.on('error', () => {}) - this.emit('job', { in: instream, out: outstream }) + this.emit('job', { in: instream, out: outputPlan }) } } @@ -726,7 +926,11 @@ function detectConflicts(jobEmitter: EventEmitter): MyEventEmitter { return } const inPath = (job.in as fs.ReadStream).path as string - const outPath = job.out.path as string + const outPath = job.out.path + if (outPath == null) { + emitter.emit('job', job) + return + } if (Object.hasOwn(outfileAssociations, outPath) && outfileAssociations[outPath] !== inPath) { emitter.emit( 'error', @@ -786,9 +990,9 @@ function makeJobEmitter( { allowOutputCollisions, recursive, - outstreamProvider, + outputPlanProvider, singleAssembly, - streamRegistry, + outputPlanRegistry, watch: watchOption, reprocessStale, }: JobEmitterOptions, @@ -802,7 +1006,7 @@ function makeJobEmitter( for (const input of inputs) { if (input === '-') { emitterFns.push( - () => new SingleJobEmitter({ file: input, outstreamProvider, streamRegistry }), + () => new SingleJobEmitter({ file: input, outputPlanProvider, outputPlanRegistry }), ) watcherFns.push(() => new NullJobEmitter()) } else { @@ -810,26 +1014,41 @@ function makeJobEmitter( if (stats.isDirectory()) { emitterFns.push( () => - new ReaddirJobEmitter({ dir: input, recursive, outstreamProvider, streamRegistry }), + new ReaddirJobEmitter({ + dir: input, + recursive, + outputPlanProvider, + outputPlanRegistry, + }), ) watcherFns.push( () => - new WatchJobEmitter({ file: input, recursive, outstreamProvider, streamRegistry }), + new WatchJobEmitter({ + file: input, + recursive, + outputPlanProvider, + outputPlanRegistry, + }), ) } else { emitterFns.push( - () => new SingleJobEmitter({ file: input, outstreamProvider, streamRegistry }), + () => new SingleJobEmitter({ file: input, outputPlanProvider, outputPlanRegistry }), ) watcherFns.push( () => - new WatchJobEmitter({ file: input, recursive, outstreamProvider, streamRegistry }), + new WatchJobEmitter({ + file: input, + recursive, + outputPlanProvider, + outputPlanRegistry, + }), ) } } } if (inputs.length === 0) { - emitterFns.push(() => new InputlessJobEmitter({ outstreamProvider, streamRegistry })) + emitterFns.push(() => new InputlessJobEmitter({ outputPlanProvider })) } startEmitting() @@ -976,21 +1195,21 @@ export async function create( params.fields = fields } - const outstreamProvider: OutstreamProvider = + const outputPlanProvider: OutputPlanProvider = resolvedOutput == null ? nullProvider() : outstat?.isDirectory() ? dirProvider(resolvedOutput) : fileProvider(resolvedOutput) - const streamRegistry: StreamRegistry = {} + const outputPlanRegistry: OutputPlanRegistry = {} const emitter = makeJobEmitter(inputs, { allowOutputCollisions: singleAssembly, + outputPlanProvider, + outputPlanRegistry, recursive, watch: watchOption, - outstreamProvider, singleAssembly, - streamRegistry, reprocessStale, }) @@ -1004,9 +1223,9 @@ export async function create( // Helper to process a single assembly job async function processAssemblyJob( inPath: string | null, - outPath: string | null, + outputPlan: OutputPlan | null, ): Promise { - outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`) + outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) // Create fresh streams for this job const inStream = inPath ? fs.createReadStream(inPath) : null @@ -1041,133 +1260,20 @@ export async function create( if (!assembly.results) throw new Error('No results in assembly') - const outIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory()) - const entries = Object.entries(assembly.results) - const allFiles: Array<{ - stepName: string - file: { name?: string; basename?: string; ext?: string; ssl_url?: string; url?: string } - }> = [] - for (const [stepName, stepResults] of entries) { - for (const file of stepResults as Array<{ - name?: string - basename?: string - ext?: string - ssl_url?: string - url?: string - }>) { - allFiles.push({ stepName, file }) - } - } - - const getFileUrl = (file: { ssl_url?: string; url?: string }): string | null => - file.ssl_url ?? file.url ?? null - - const sanitizeName = (value: string): string => { - const base = path.basename(value) - return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '') - } - - const ensureUniquePath = async (targetPath: string): Promise => { - const parsed = path.parse(targetPath) - let candidate = targetPath - let counter = 1 - while (true) { - const [statErr] = await tryCatch(fsp.stat(candidate)) - if (statErr) return candidate - candidate = path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`) - counter += 1 - } - } - - const shouldGroupByInput = inPath != null && (hasDirectoryInput || inputs.length > 1) - const useIntentDirectoryLayout = outputMode === 'directory' - - const resolveDirectoryBaseDir = (): string => { - if (!shouldGroupByInput || inPath == null) { - return resolvedOutput as string - } - - if (hasDirectoryInput && outPath != null) { - const mappedRelative = path.relative(resolvedOutput as string, outPath) - const mappedDir = path.dirname(mappedRelative) - const mappedStem = path.parse(mappedRelative).name - return path.join(resolvedOutput as string, mappedDir === '.' ? '' : mappedDir, mappedStem) - } - - return path.join(resolvedOutput as string, path.parse(path.basename(inPath)).name) - } - - const downloadResultFile = async (resultUrl: string, targetPath: string): Promise => { - outputctl.debug('DOWNLOADING') - const [dlErr] = await tryCatch( - downloadResultToFile(resultUrl, targetPath, abortController.signal), - ) - if (dlErr) { - if (dlErr.name === 'AbortError') return - outputctl.error(dlErr.message) - throw dlErr - } - } - - if (resolvedOutput != null) { - if (outIsDirectory) { - if (useIntentDirectoryLayout || outPath == null) { - const baseDir = resolveDirectoryBaseDir() - await fsp.mkdir(baseDir, { recursive: true }) - const shouldUseStepDirectories = entries.length > 1 - - for (const { stepName, file } of allFiles) { - const resultUrl = getFileUrl(file) - if (!resultUrl) continue - - const targetDir = shouldUseStepDirectories ? path.join(baseDir, stepName) : baseDir - await fsp.mkdir(targetDir, { recursive: true }) - - const rawName = - file.name ?? - (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? - `${stepName}_result` - const safeName = sanitizeName(rawName) - const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) - - await downloadResultFile(resultUrl, targetPath) - } - } else if (allFiles.length === 1) { - const first = allFiles[0] - const resultUrl = first ? getFileUrl(first.file) : null - if (resultUrl) { - await downloadResultFile(resultUrl, outPath) - } - } else { - const legacyBaseDir = path.join(path.dirname(outPath), path.parse(outPath).name) - - for (const { stepName, file } of allFiles) { - const resultUrl = getFileUrl(file) - if (!resultUrl) continue - - const targetDir = path.join(legacyBaseDir, stepName) - await fsp.mkdir(targetDir, { recursive: true }) - - const rawName = - file.name ?? - (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? - `${stepName}_result` - const safeName = sanitizeName(rawName) - const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) - - await downloadResultFile(resultUrl, targetPath) - } - } - } else if (outPath != null) { - const first = allFiles[0] - const resultUrl = first ? getFileUrl(first.file) : null - if (resultUrl) { - await downloadResultFile(resultUrl, outPath) - } - } - } + await materializeAssemblyResults({ + abortSignal: abortController.signal, + hasDirectoryInput, + inPath, + inputs, + outputMode, + outputPath: outputPlan?.path ?? null, + outputRoot: resolvedOutput ?? null, + outputRootIsDirectory: Boolean(resolvedOutput != null && outstat?.isDirectory()), + outputctl, + results: assembly.results, + }) - outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outPath ?? 'null'}`) + outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) if (del && inPath) { await fsp.unlink(inPath) @@ -1248,32 +1354,20 @@ export async function create( throw new Error(msg) } - // Download all results - if (asm.results && resolvedOutput != null) { - for (const [stepName, stepResults] of Object.entries(asm.results)) { - for (const stepResult of stepResults) { - const resultUrl = - (stepResult as { ssl_url?: string; url?: string }).ssl_url ?? stepResult.url - if (!resultUrl) continue - - let outPath: string - if (outstat?.isDirectory()) { - outPath = path.join(resolvedOutput, stepResult.name || `${stepName}_result`) - } else { - outPath = resolvedOutput - } - - outputctl.debug(`DOWNLOADING ${stepResult.name} to ${outPath}`) - const [dlErr] = await tryCatch( - downloadResultToFile(resultUrl, outPath, abortController.signal), - ) - if (dlErr) { - if (dlErr.name === 'AbortError') continue - outputctl.error(dlErr.message) - throw dlErr - } - } - } + if (asm.results) { + await materializeAssemblyResults({ + abortSignal: abortController.signal, + hasDirectoryInput: false, + inPath: null, + inputs: inputPaths, + outputMode, + outputPath: resolvedOutput ?? null, + outputRoot: resolvedOutput ?? null, + outputRootIsDirectory: Boolean(resolvedOutput != null && outstat?.isDirectory()), + outputctl, + results: asm.results, + singleAssembly: true, + }) } // Delete input files if requested @@ -1298,21 +1392,17 @@ export async function create( const inPath = job.in ? (((job.in as fs.ReadStream).path as string | undefined) ?? null) : null - const outPath = job.out?.path ?? null - outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`) + const outputPlan = job.out + outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) // Close the original streams immediately - we'll create fresh ones when processing if (job.in != null) { ;(job.in as fs.ReadStream).destroy() } - if (job.out != null) { - job.out.destroy() - } - // Add job to queue - p-queue handles concurrency automatically queue .add(async () => { - const result = await processAssemblyJob(inPath, outPath) + const result = await processAssemblyJob(inPath, outputPlan) if (result !== undefined) { results.push(result) } diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index a8b719ea..255ca4c6 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -2,7 +2,6 @@ // Generated by `packages/node/scripts/generate-intent-commands.ts`. import { Command, Option } from 'clipanion' -import * as t from 'typanion' import { robotAudioWaveformInstructionsSchema } from '../../alphalib/types/robots/audio-waveform.ts' import { robotDocumentAutorotateInstructionsSchema } from '../../alphalib/types/robots/document-autorotate.ts' @@ -18,11 +17,415 @@ import { robotImageOptimizeInstructionsSchema } from '../../alphalib/types/robot import { robotImageResizeInstructionsSchema } from '../../alphalib/types/robots/image-resize.ts' import { robotTextSpeakInstructionsSchema } from '../../alphalib/types/robots/text-speak.ts' import { robotVideoThumbsInstructionsSchema } from '../../alphalib/types/robots/video-thumbs.ts' -import { parseIntentStep, prepareIntentInputs } from '../intentRuntime.ts' -import * as assembliesCommands from './assemblies.ts' -import { AuthenticatedCommand } from './BaseCommand.ts' - -class ImageGenerateCommand extends AuthenticatedCommand { +import { + GeneratedBundledFileIntentCommand, + GeneratedNoInputIntentCommand, + GeneratedStandardFileIntentCommand, +} from '../intentRuntime.ts' + +const imageGenerateCommandDefinition = { + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotImageGenerateInstructionsSchema, + fieldSpecs: [ + { name: 'model', kind: 'string' }, + { name: 'prompt', kind: 'string' }, + { name: 'format', kind: 'string' }, + { name: 'seed', kind: 'number' }, + { name: 'aspect_ratio', kind: 'string' }, + { name: 'height', kind: 'number' }, + { name: 'width', kind: 'number' }, + { name: 'style', kind: 'string' }, + { name: 'num_outputs', kind: 'number' }, + ], + fixedValues: { + robot: '/image/generate', + result: true, + }, + resultStepName: 'generate', + }, +} as const + +const previewGenerateCommandDefinition = { + commandLabel: 'preview generate', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotFilePreviewInstructionsSchema, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'artwork_outer_color', kind: 'string' }, + { name: 'artwork_center_color', kind: 'string' }, + { name: 'waveform_center_color', kind: 'string' }, + { name: 'waveform_outer_color', kind: 'string' }, + { name: 'waveform_height', kind: 'number' }, + { name: 'waveform_width', kind: 'number' }, + { name: 'icon_style', kind: 'string' }, + { name: 'icon_text_color', kind: 'string' }, + { name: 'icon_text_font', kind: 'string' }, + { name: 'icon_text_content', kind: 'string' }, + { name: 'optimize', kind: 'boolean' }, + { name: 'optimize_priority', kind: 'string' }, + { name: 'optimize_progressive', kind: 'boolean' }, + { name: 'clip_format', kind: 'string' }, + { name: 'clip_offset', kind: 'number' }, + { name: 'clip_duration', kind: 'number' }, + { name: 'clip_framerate', kind: 'number' }, + { name: 'clip_loop', kind: 'boolean' }, + ], + fixedValues: { + robot: '/file/preview', + result: true, + use: ':original', + }, + resultStepName: 'generate', + }, +} as const + +const imageRemoveBackgroundCommandDefinition = { + commandLabel: 'image remove-background', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotImageBgremoveInstructionsSchema, + fieldSpecs: [ + { name: 'select', kind: 'string' }, + { name: 'format', kind: 'string' }, + { name: 'provider', kind: 'string' }, + { name: 'model', kind: 'string' }, + ], + fixedValues: { + robot: '/image/bgremove', + result: true, + use: ':original', + }, + resultStepName: 'remove_background', + }, +} as const + +const imageOptimizeCommandDefinition = { + commandLabel: 'image optimize', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotImageOptimizeInstructionsSchema, + fieldSpecs: [ + { name: 'priority', kind: 'string' }, + { name: 'progressive', kind: 'boolean' }, + { name: 'preserve_meta_data', kind: 'boolean' }, + { name: 'fix_breaking_images', kind: 'boolean' }, + ], + fixedValues: { + robot: '/image/optimize', + result: true, + use: ':original', + }, + resultStepName: 'optimize', + }, +} as const + +const imageResizeCommandDefinition = { + commandLabel: 'image resize', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotImageResizeInstructionsSchema, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'zoom', kind: 'boolean' }, + { name: 'gravity', kind: 'string' }, + { name: 'strip', kind: 'boolean' }, + { name: 'alpha', kind: 'string' }, + { name: 'preclip_alpha', kind: 'string' }, + { name: 'flatten', kind: 'boolean' }, + { name: 'correct_gamma', kind: 'boolean' }, + { name: 'quality', kind: 'number' }, + { name: 'adaptive_filtering', kind: 'boolean' }, + { name: 'background', kind: 'string' }, + { name: 'frame', kind: 'number' }, + { name: 'colorspace', kind: 'string' }, + { name: 'type', kind: 'string' }, + { name: 'sepia', kind: 'number' }, + { name: 'rotation', kind: 'auto' }, + { name: 'compress', kind: 'string' }, + { name: 'blur', kind: 'string' }, + { name: 'brightness', kind: 'number' }, + { name: 'saturation', kind: 'number' }, + { name: 'hue', kind: 'number' }, + { name: 'contrast', kind: 'number' }, + { name: 'watermark_url', kind: 'string' }, + { name: 'watermark_x_offset', kind: 'number' }, + { name: 'watermark_y_offset', kind: 'number' }, + { name: 'watermark_size', kind: 'string' }, + { name: 'watermark_resize_strategy', kind: 'string' }, + { name: 'watermark_opacity', kind: 'number' }, + { name: 'watermark_repeat_x', kind: 'boolean' }, + { name: 'watermark_repeat_y', kind: 'boolean' }, + { name: 'progressive', kind: 'boolean' }, + { name: 'transparent', kind: 'string' }, + { name: 'trim_whitespace', kind: 'boolean' }, + { name: 'clip', kind: 'auto' }, + { name: 'negate', kind: 'boolean' }, + { name: 'density', kind: 'string' }, + { name: 'monochrome', kind: 'boolean' }, + { name: 'shave', kind: 'auto' }, + ], + fixedValues: { + robot: '/image/resize', + result: true, + use: ':original', + }, + resultStepName: 'resize', + }, +} as const + +const documentConvertCommandDefinition = { + commandLabel: 'document convert', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotDocumentConvertInstructionsSchema, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'markdown_format', kind: 'string' }, + { name: 'markdown_theme', kind: 'string' }, + { name: 'pdf_margin', kind: 'string' }, + { name: 'pdf_print_background', kind: 'boolean' }, + { name: 'pdf_format', kind: 'string' }, + { name: 'pdf_display_header_footer', kind: 'boolean' }, + { name: 'pdf_header_template', kind: 'string' }, + { name: 'pdf_footer_template', kind: 'string' }, + ], + fixedValues: { + robot: '/document/convert', + result: true, + use: ':original', + }, + resultStepName: 'convert', + }, +} as const + +const documentOptimizeCommandDefinition = { + commandLabel: 'document optimize', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotDocumentOptimizeInstructionsSchema, + fieldSpecs: [ + { name: 'preset', kind: 'string' }, + { name: 'image_dpi', kind: 'number' }, + { name: 'compress_fonts', kind: 'boolean' }, + { name: 'subset_fonts', kind: 'boolean' }, + { name: 'remove_metadata', kind: 'boolean' }, + { name: 'linearize', kind: 'boolean' }, + { name: 'compatibility', kind: 'string' }, + ], + fixedValues: { + robot: '/document/optimize', + result: true, + use: ':original', + }, + resultStepName: 'optimize', + }, +} as const + +const documentAutoRotateCommandDefinition = { + commandLabel: 'document auto-rotate', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotDocumentAutorotateInstructionsSchema, + fieldSpecs: [], + fixedValues: { + robot: '/document/autorotate', + result: true, + use: ':original', + }, + resultStepName: 'auto_rotate', + }, +} as const + +const documentThumbsCommandDefinition = { + commandLabel: 'document thumbs', + outputMode: 'directory', + execution: { + kind: 'single-step', + schema: robotDocumentThumbsInstructionsSchema, + fieldSpecs: [ + { name: 'page', kind: 'number' }, + { name: 'format', kind: 'string' }, + { name: 'delay', kind: 'number' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'alpha', kind: 'string' }, + { name: 'density', kind: 'string' }, + { name: 'antialiasing', kind: 'boolean' }, + { name: 'colorspace', kind: 'string' }, + { name: 'trim_whitespace', kind: 'boolean' }, + { name: 'pdf_use_cropbox', kind: 'boolean' }, + { name: 'turbo', kind: 'boolean' }, + ], + fixedValues: { + robot: '/document/thumbs', + result: true, + use: ':original', + }, + resultStepName: 'thumbs', + }, +} as const + +const audioWaveformCommandDefinition = { + commandLabel: 'audio waveform', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotAudioWaveformInstructionsSchema, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'antialiasing', kind: 'auto' }, + { name: 'background_color', kind: 'string' }, + { name: 'center_color', kind: 'string' }, + { name: 'outer_color', kind: 'string' }, + { name: 'style', kind: 'string' }, + { name: 'split_channels', kind: 'boolean' }, + { name: 'zoom', kind: 'number' }, + { name: 'pixels_per_second', kind: 'number' }, + { name: 'bits', kind: 'number' }, + { name: 'start', kind: 'number' }, + { name: 'end', kind: 'number' }, + { name: 'colors', kind: 'string' }, + { name: 'border_color', kind: 'string' }, + { name: 'waveform_style', kind: 'string' }, + { name: 'bar_width', kind: 'number' }, + { name: 'bar_gap', kind: 'number' }, + { name: 'bar_style', kind: 'string' }, + { name: 'axis_label_color', kind: 'string' }, + { name: 'no_axis_labels', kind: 'boolean' }, + { name: 'with_axis_labels', kind: 'boolean' }, + { name: 'amplitude_scale', kind: 'number' }, + { name: 'compression', kind: 'number' }, + ], + fixedValues: { + robot: '/audio/waveform', + result: true, + use: ':original', + }, + resultStepName: 'waveform', + }, +} as const + +const textSpeakCommandDefinition = { + commandLabel: 'text speak', + requiredFieldForInputless: 'prompt', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotTextSpeakInstructionsSchema, + fieldSpecs: [ + { name: 'prompt', kind: 'string' }, + { name: 'provider', kind: 'string' }, + { name: 'target_language', kind: 'string' }, + { name: 'voice', kind: 'string' }, + { name: 'ssml', kind: 'boolean' }, + ], + fixedValues: { + robot: '/text/speak', + result: true, + }, + resultStepName: 'speak', + attachUseWhenInputsProvided: true, + }, +} as const + +const videoThumbsCommandDefinition = { + commandLabel: 'video thumbs', + outputMode: 'directory', + execution: { + kind: 'single-step', + schema: robotVideoThumbsInstructionsSchema, + fieldSpecs: [ + { name: 'count', kind: 'number' }, + { name: 'format', kind: 'string' }, + { name: 'width', kind: 'number' }, + { name: 'height', kind: 'number' }, + { name: 'resize_strategy', kind: 'string' }, + { name: 'background', kind: 'string' }, + { name: 'rotate', kind: 'number' }, + { name: 'input_codec', kind: 'string' }, + ], + fixedValues: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + resultStepName: 'thumbs', + }, +} as const + +const videoEncodeHlsCommandDefinition = { + commandLabel: 'video encode-hls', + outputMode: 'directory', + execution: { + kind: 'template', + templateId: 'builtin/encode-hls-video@latest', + }, +} as const + +const fileCompressCommandDefinition = { + commandLabel: 'file compress', + outputMode: 'file', + execution: { + kind: 'single-step', + schema: robotFileCompressInstructionsSchema, + fieldSpecs: [ + { name: 'format', kind: 'string' }, + { name: 'gzip', kind: 'boolean' }, + { name: 'password', kind: 'string' }, + { name: 'compression_level', kind: 'number' }, + { name: 'file_layout', kind: 'string' }, + { name: 'archive_name', kind: 'string' }, + ], + fixedValues: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + resultStepName: 'compress', + }, +} as const + +const fileDecompressCommandDefinition = { + commandLabel: 'file decompress', + outputMode: 'directory', + execution: { + kind: 'single-step', + schema: robotFileDecompressInstructionsSchema, + fieldSpecs: [], + fixedValues: { + robot: '/file/decompress', + result: true, + use: ':original', + }, + resultStepName: 'decompress', + }, +} as const + +class ImageGenerateCommand extends GeneratedNoInputIntentCommand { static override paths = [['image', 'generate']] static override usage = Command.Usage({ @@ -37,6 +440,13 @@ class ImageGenerateCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = imageGenerateCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path', + required: true, + }) + model = Option.String('--model', { description: 'The AI model to use for image generation. Defaults to google/nano-banana.', }) @@ -74,55 +484,22 @@ class ImageGenerateCommand extends AuthenticatedCommand { description: 'Number of image variants to generate.', }) - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path', - required: true, - }) - - protected async run(): Promise { - const step = parseIntentStep({ - schema: robotImageGenerateInstructionsSchema, - fixedValues: { - robot: '/image/generate', - result: true, - }, - fieldSpecs: [ - { name: 'model', kind: 'string' }, - { name: 'prompt', kind: 'string' }, - { name: 'format', kind: 'string' }, - { name: 'seed', kind: 'number' }, - { name: 'aspect_ratio', kind: 'string' }, - { name: 'height', kind: 'number' }, - { name: 'width', kind: 'number' }, - { name: 'style', kind: 'string' }, - { name: 'num_outputs', kind: 'number' }, - ], - rawValues: { - model: this.model, - prompt: this.prompt, - format: this.format, - seed: this.seed, - aspect_ratio: this.aspectRatio, - height: this.height, - width: this.width, - style: this.style, - num_outputs: this.numOutputs, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - generated_image: step, - }, - inputs: [], - output: this.outputPath, - }) - - return hasFailures ? 1 : undefined + protected override getIntentRawValues(): Record { + return { + model: this.model, + prompt: this.prompt, + format: this.format, + seed: this.seed, + aspect_ratio: this.aspectRatio, + height: this.height, + width: this.width, + style: this.style, + num_outputs: this.numOutputs, + } } } -class PreviewGenerateCommand extends AuthenticatedCommand { +class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { static override paths = [['preview', 'generate']] static override usage = Command.Usage({ @@ -134,6 +511,13 @@ class PreviewGenerateCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = previewGenerateCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + format = Option.String('--format', { description: 'The output format for the generated thumbnail image. If a short video clip is generated using the `clip` strategy, its format is defined by `clip_format`.', @@ -245,148 +629,36 @@ class PreviewGenerateCommand extends AuthenticatedCommand { 'Specifies whether the generated animated image should loop forever (`true`) or stop after playing the animation once (`false`). Only used if the `clip` strategy for video files is applied.', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('preview generate requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotFilePreviewInstructionsSchema, - fixedValues: { - robot: '/file/preview', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'artwork_outer_color', kind: 'string' }, - { name: 'artwork_center_color', kind: 'string' }, - { name: 'waveform_center_color', kind: 'string' }, - { name: 'waveform_outer_color', kind: 'string' }, - { name: 'waveform_height', kind: 'number' }, - { name: 'waveform_width', kind: 'number' }, - { name: 'icon_style', kind: 'string' }, - { name: 'icon_text_color', kind: 'string' }, - { name: 'icon_text_font', kind: 'string' }, - { name: 'icon_text_content', kind: 'string' }, - { name: 'optimize', kind: 'boolean' }, - { name: 'optimize_priority', kind: 'string' }, - { name: 'optimize_progressive', kind: 'boolean' }, - { name: 'clip_format', kind: 'string' }, - { name: 'clip_offset', kind: 'number' }, - { name: 'clip_duration', kind: 'number' }, - { name: 'clip_framerate', kind: 'number' }, - { name: 'clip_loop', kind: 'boolean' }, - ], - rawValues: { - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - artwork_outer_color: this.artworkOuterColor, - artwork_center_color: this.artworkCenterColor, - waveform_center_color: this.waveformCenterColor, - waveform_outer_color: this.waveformOuterColor, - waveform_height: this.waveformHeight, - waveform_width: this.waveformWidth, - icon_style: this.iconStyle, - icon_text_color: this.iconTextColor, - icon_text_font: this.iconTextFont, - icon_text_content: this.iconTextContent, - optimize: this.optimize, - optimize_priority: this.optimizePriority, - optimize_progressive: this.optimizeProgressive, - clip_format: this.clipFormat, - clip_offset: this.clipOffset, - clip_duration: this.clipDuration, - clip_framerate: this.clipFramerate, - clip_loop: this.clipLoop, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - preview: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + artwork_outer_color: this.artworkOuterColor, + artwork_center_color: this.artworkCenterColor, + waveform_center_color: this.waveformCenterColor, + waveform_outer_color: this.waveformOuterColor, + waveform_height: this.waveformHeight, + waveform_width: this.waveformWidth, + icon_style: this.iconStyle, + icon_text_color: this.iconTextColor, + icon_text_font: this.iconTextFont, + icon_text_content: this.iconTextContent, + optimize: this.optimize, + optimize_priority: this.optimizePriority, + optimize_progressive: this.optimizeProgressive, + clip_format: this.clipFormat, + clip_offset: this.clipOffset, + clip_duration: this.clipDuration, + clip_framerate: this.clipFramerate, + clip_loop: this.clipLoop, } } } -class ImageRemoveBackgroundCommand extends AuthenticatedCommand { +class ImageRemoveBackgroundCommand extends GeneratedStandardFileIntentCommand { static override paths = [['image', 'remove-background']] static override usage = Command.Usage({ @@ -398,6 +670,13 @@ class ImageRemoveBackgroundCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = imageRemoveBackgroundCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + select = Option.String('--select', { description: 'Region to select and keep in the image. The other region is removed.', }) @@ -415,110 +694,17 @@ class ImageRemoveBackgroundCommand extends AuthenticatedCommand { 'Provider-specific model to use for removing the background. Mostly intended for testing and evaluation.', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('image remove-background requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotImageBgremoveInstructionsSchema, - fixedValues: { - robot: '/image/bgremove', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'select', kind: 'string' }, - { name: 'format', kind: 'string' }, - { name: 'provider', kind: 'string' }, - { name: 'model', kind: 'string' }, - ], - rawValues: { - select: this.select, - format: this.format, - provider: this.provider, - model: this.model, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - removed_background: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + select: this.select, + format: this.format, + provider: this.provider, + model: this.model, } } } -class ImageOptimizeCommand extends AuthenticatedCommand { +class ImageOptimizeCommand extends GeneratedStandardFileIntentCommand { static override paths = [['image', 'optimize']] static override usage = Command.Usage({ @@ -530,6 +716,13 @@ class ImageOptimizeCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = imageOptimizeCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + priority = Option.String('--priority', { description: 'Provides different algorithms for better or worse compression for your images, but that run slower or faster. The value `"conversion-speed"` will result in an average compression ratio of 18%. `"compression-ratio"` will result in an average compression ratio of 31%.', @@ -550,110 +743,17 @@ class ImageOptimizeCommand extends AuthenticatedCommand { 'If set to `true` this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies. This can sometimes result in a larger file size, though.', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('image optimize requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotImageOptimizeInstructionsSchema, - fixedValues: { - robot: '/image/optimize', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'priority', kind: 'string' }, - { name: 'progressive', kind: 'boolean' }, - { name: 'preserve_meta_data', kind: 'boolean' }, - { name: 'fix_breaking_images', kind: 'boolean' }, - ], - rawValues: { - priority: this.priority, - progressive: this.progressive, - preserve_meta_data: this.preserveMetaData, - fix_breaking_images: this.fixBreakingImages, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - optimized: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + priority: this.priority, + progressive: this.progressive, + preserve_meta_data: this.preserveMetaData, + fix_breaking_images: this.fixBreakingImages, } } } -class ImageResizeCommand extends AuthenticatedCommand { +class ImageResizeCommand extends GeneratedStandardFileIntentCommand { static override paths = [['image', 'resize']] static override usage = Command.Usage({ @@ -663,6 +763,13 @@ class ImageResizeCommand extends AuthenticatedCommand { examples: [['Run the command', 'transloadit image resize --input input.png --out output.png']], }) + protected override readonly intentDefinition = imageResizeCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + format = Option.String('--format', { description: 'The output format for the modified image.\n\nSome of the most important available formats are `"jpg"`, `"png"`, `"gif"`, and `"tiff"`. For a complete lists of all formats that we can write to please check [our supported image formats list](/docs/supported-formats/image-formats/).\n\nIf `null` (default), then the input image\'s format will be used as the output format.\n\nIf you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead.', @@ -864,184 +971,54 @@ class ImageResizeCommand extends AuthenticatedCommand { 'Shave pixels from the image edges. The value should be in the format `width` or `width`x`height` to specify the number of pixels to remove from each side.', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('image resize requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotImageResizeInstructionsSchema, - fixedValues: { - robot: '/image/resize', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'zoom', kind: 'boolean' }, - { name: 'gravity', kind: 'string' }, - { name: 'strip', kind: 'boolean' }, - { name: 'alpha', kind: 'string' }, - { name: 'preclip_alpha', kind: 'string' }, - { name: 'flatten', kind: 'boolean' }, - { name: 'correct_gamma', kind: 'boolean' }, - { name: 'quality', kind: 'number' }, - { name: 'adaptive_filtering', kind: 'boolean' }, - { name: 'background', kind: 'string' }, - { name: 'frame', kind: 'number' }, - { name: 'colorspace', kind: 'string' }, - { name: 'type', kind: 'string' }, - { name: 'sepia', kind: 'number' }, - { name: 'rotation', kind: 'auto' }, - { name: 'compress', kind: 'string' }, - { name: 'blur', kind: 'string' }, - { name: 'brightness', kind: 'number' }, - { name: 'saturation', kind: 'number' }, - { name: 'hue', kind: 'number' }, - { name: 'contrast', kind: 'number' }, - { name: 'watermark_url', kind: 'string' }, - { name: 'watermark_x_offset', kind: 'number' }, - { name: 'watermark_y_offset', kind: 'number' }, - { name: 'watermark_size', kind: 'string' }, - { name: 'watermark_resize_strategy', kind: 'string' }, - { name: 'watermark_opacity', kind: 'number' }, - { name: 'watermark_repeat_x', kind: 'boolean' }, - { name: 'watermark_repeat_y', kind: 'boolean' }, - { name: 'progressive', kind: 'boolean' }, - { name: 'transparent', kind: 'string' }, - { name: 'trim_whitespace', kind: 'boolean' }, - { name: 'clip', kind: 'auto' }, - { name: 'negate', kind: 'boolean' }, - { name: 'density', kind: 'string' }, - { name: 'monochrome', kind: 'boolean' }, - { name: 'shave', kind: 'auto' }, - ], - rawValues: { - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - zoom: this.zoom, - gravity: this.gravity, - strip: this.strip, - alpha: this.alpha, - preclip_alpha: this.preclipAlpha, - flatten: this.flatten, - correct_gamma: this.correctGamma, - quality: this.quality, - adaptive_filtering: this.adaptiveFiltering, - background: this.background, - frame: this.frame, - colorspace: this.colorspace, - type: this.type, - sepia: this.sepia, - rotation: this.rotation, - compress: this.compress, - blur: this.blur, - brightness: this.brightness, - saturation: this.saturation, - hue: this.hue, - contrast: this.contrast, - watermark_url: this.watermarkUrl, - watermark_x_offset: this.watermarkXOffset, - watermark_y_offset: this.watermarkYOffset, - watermark_size: this.watermarkSize, - watermark_resize_strategy: this.watermarkResizeStrategy, - watermark_opacity: this.watermarkOpacity, - watermark_repeat_x: this.watermarkRepeatX, - watermark_repeat_y: this.watermarkRepeatY, - progressive: this.progressive, - transparent: this.transparent, - trim_whitespace: this.trimWhitespace, - clip: this.clip, - negate: this.negate, - density: this.density, - monochrome: this.monochrome, - shave: this.shave, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - resized: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + zoom: this.zoom, + gravity: this.gravity, + strip: this.strip, + alpha: this.alpha, + preclip_alpha: this.preclipAlpha, + flatten: this.flatten, + correct_gamma: this.correctGamma, + quality: this.quality, + adaptive_filtering: this.adaptiveFiltering, + background: this.background, + frame: this.frame, + colorspace: this.colorspace, + type: this.type, + sepia: this.sepia, + rotation: this.rotation, + compress: this.compress, + blur: this.blur, + brightness: this.brightness, + saturation: this.saturation, + hue: this.hue, + contrast: this.contrast, + watermark_url: this.watermarkUrl, + watermark_x_offset: this.watermarkXOffset, + watermark_y_offset: this.watermarkYOffset, + watermark_size: this.watermarkSize, + watermark_resize_strategy: this.watermarkResizeStrategy, + watermark_opacity: this.watermarkOpacity, + watermark_repeat_x: this.watermarkRepeatX, + watermark_repeat_y: this.watermarkRepeatY, + progressive: this.progressive, + transparent: this.transparent, + trim_whitespace: this.trimWhitespace, + clip: this.clip, + negate: this.negate, + density: this.density, + monochrome: this.monochrome, + shave: this.shave, } } } -class DocumentConvertCommand extends AuthenticatedCommand { +class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'convert']] static override usage = Command.Usage({ @@ -1056,6 +1033,13 @@ class DocumentConvertCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = documentConvertCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + format = Option.String('--format', { description: 'The desired format for document conversion.', required: true, @@ -1101,120 +1085,22 @@ class DocumentConvertCommand extends AuthenticatedCommand { 'HTML template for the PDF print footer.\n\nShould use the same format as the `pdf_header_template`.\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number in the footer you\'d use the following HTML for the footer template:\n\n```html\n
\n```', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('document convert requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotDocumentConvertInstructionsSchema, - fixedValues: { - robot: '/document/convert', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'markdown_format', kind: 'string' }, - { name: 'markdown_theme', kind: 'string' }, - { name: 'pdf_margin', kind: 'string' }, - { name: 'pdf_print_background', kind: 'boolean' }, - { name: 'pdf_format', kind: 'string' }, - { name: 'pdf_display_header_footer', kind: 'boolean' }, - { name: 'pdf_header_template', kind: 'string' }, - { name: 'pdf_footer_template', kind: 'string' }, - ], - rawValues: { - format: this.format, - markdown_format: this.markdownFormat, - markdown_theme: this.markdownTheme, - pdf_margin: this.pdfMargin, - pdf_print_background: this.pdfPrintBackground, - pdf_format: this.pdfFormat, - pdf_display_header_footer: this.pdfDisplayHeaderFooter, - pdf_header_template: this.pdfHeaderTemplate, - pdf_footer_template: this.pdfFooterTemplate, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - converted: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + format: this.format, + markdown_format: this.markdownFormat, + markdown_theme: this.markdownTheme, + pdf_margin: this.pdfMargin, + pdf_print_background: this.pdfPrintBackground, + pdf_format: this.pdfFormat, + pdf_display_header_footer: this.pdfDisplayHeaderFooter, + pdf_header_template: this.pdfHeaderTemplate, + pdf_footer_template: this.pdfFooterTemplate, } } } -class DocumentOptimizeCommand extends AuthenticatedCommand { +class DocumentOptimizeCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'optimize']] static override usage = Command.Usage({ @@ -1226,6 +1112,13 @@ class DocumentOptimizeCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = documentOptimizeCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + preset = Option.String('--preset', { description: 'The quality preset to use for optimization. Each preset provides a different balance between file size and quality:\n\n- `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI.\n- `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI.\n- `printer` - High quality suitable for printing. Images are kept at 300 DPI.\n- `prepress` - Highest quality for professional printing. Minimal compression applied.', @@ -1261,116 +1154,20 @@ class DocumentOptimizeCommand extends AuthenticatedCommand { 'The PDF version compatibility level. Lower versions have broader compatibility but fewer features. Higher versions support more advanced features but may not open in older PDF readers.\n\n- `1.4` - Acrobat 5 compatibility, most widely supported\n- `1.5` - Acrobat 6 compatibility\n- `1.6` - Acrobat 7 compatibility\n- `1.7` - Acrobat 8+ compatibility (default)\n- `2.0` - PDF 2.0 standard', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('document optimize requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotDocumentOptimizeInstructionsSchema, - fixedValues: { - robot: '/document/optimize', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'preset', kind: 'string' }, - { name: 'image_dpi', kind: 'number' }, - { name: 'compress_fonts', kind: 'boolean' }, - { name: 'subset_fonts', kind: 'boolean' }, - { name: 'remove_metadata', kind: 'boolean' }, - { name: 'linearize', kind: 'boolean' }, - { name: 'compatibility', kind: 'string' }, - ], - rawValues: { - preset: this.preset, - image_dpi: this.imageDpi, - compress_fonts: this.compressFonts, - subset_fonts: this.subsetFonts, - remove_metadata: this.removeMetadata, - linearize: this.linearize, - compatibility: this.compatibility, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - optimized: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + preset: this.preset, + image_dpi: this.imageDpi, + compress_fonts: this.compressFonts, + subset_fonts: this.subsetFonts, + remove_metadata: this.removeMetadata, + linearize: this.linearize, + compatibility: this.compatibility, } } } -class DocumentAutoRotateCommand extends AuthenticatedCommand { +class DocumentAutoRotateCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'auto-rotate']] static override usage = Command.Usage({ @@ -1382,100 +1179,19 @@ class DocumentAutoRotateCommand extends AuthenticatedCommand { ], }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) + protected override readonly intentDefinition = documentAutoRotateCommandDefinition - outputPath = Option.String('--out,-o', { + override outputPath = Option.String('--out,-o', { description: 'Write the result to this path or directory', required: true, }) - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('document auto-rotate requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotDocumentAutorotateInstructionsSchema, - fixedValues: { - robot: '/document/autorotate', - result: true, - use: ':original', - }, - fieldSpecs: [], - rawValues: {}, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - autorotated: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) - } + protected override getIntentRawValues(): Record { + return {} } } -class DocumentThumbsCommand extends AuthenticatedCommand { +class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'thumbs']] static override usage = Command.Usage({ @@ -1485,6 +1201,13 @@ class DocumentThumbsCommand extends AuthenticatedCommand { examples: [['Run the command', 'transloadit document thumbs --input input.pdf --out output/']], }) + protected override readonly intentDefinition = documentThumbsCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the results to this directory', + required: true, + }) + page = Option.String('--page', { description: 'The PDF page that you want to convert to an image. By default the value is `null` which means that all pages will be converted into images.', @@ -1554,130 +1277,27 @@ class DocumentThumbsCommand extends AuthenticatedCommand { "If you set this to `false`, the robot will not emit files as they become available. This is useful if you are only interested in the final result and not in the intermediate steps.\n\nAlso, extracted pages will be resized a lot faster as they are sent off to other machines for the resizing. This is especially useful for large documents with many pages to get up to 20 times faster processing.\n\nTurbo Mode increases pricing, though, in that the input document's file size is added for every extracted page. There are no performance benefits nor increased charges for single-page documents.", }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the results to this directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('document thumbs requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotDocumentThumbsInstructionsSchema, - fixedValues: { - robot: '/document/thumbs', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'page', kind: 'number' }, - { name: 'format', kind: 'string' }, - { name: 'delay', kind: 'number' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'alpha', kind: 'string' }, - { name: 'density', kind: 'string' }, - { name: 'antialiasing', kind: 'boolean' }, - { name: 'colorspace', kind: 'string' }, - { name: 'trim_whitespace', kind: 'boolean' }, - { name: 'pdf_use_cropbox', kind: 'boolean' }, - { name: 'turbo', kind: 'boolean' }, - ], - rawValues: { - page: this.page, - format: this.format, - delay: this.delay, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - alpha: this.alpha, - density: this.density, - antialiasing: this.antialiasing, - colorspace: this.colorspace, - trim_whitespace: this.trimWhitespace, - pdf_use_cropbox: this.pdfUseCropbox, - turbo: this.turbo, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - thumbnailed: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + page: this.page, + format: this.format, + delay: this.delay, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + alpha: this.alpha, + density: this.density, + antialiasing: this.antialiasing, + colorspace: this.colorspace, + trim_whitespace: this.trimWhitespace, + pdf_use_cropbox: this.pdfUseCropbox, + turbo: this.turbo, } } } -class AudioWaveformCommand extends AuthenticatedCommand { +class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { static override paths = [['audio', 'waveform']] static override usage = Command.Usage({ @@ -1689,6 +1309,13 @@ class AudioWaveformCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = audioWaveformCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + format = Option.String('--format', { description: 'The format of the result file. Can be `"image"` or `"json"`. If `"image"` is supplied, a PNG image will be created, otherwise a JSON file.', @@ -1804,152 +1431,38 @@ class AudioWaveformCommand extends AuthenticatedCommand { 'Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image".', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('audio waveform requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotAudioWaveformInstructionsSchema, - fixedValues: { - robot: '/audio/waveform', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'antialiasing', kind: 'auto' }, - { name: 'background_color', kind: 'string' }, - { name: 'center_color', kind: 'string' }, - { name: 'outer_color', kind: 'string' }, - { name: 'style', kind: 'string' }, - { name: 'split_channels', kind: 'boolean' }, - { name: 'zoom', kind: 'number' }, - { name: 'pixels_per_second', kind: 'number' }, - { name: 'bits', kind: 'number' }, - { name: 'start', kind: 'number' }, - { name: 'end', kind: 'number' }, - { name: 'colors', kind: 'string' }, - { name: 'border_color', kind: 'string' }, - { name: 'waveform_style', kind: 'string' }, - { name: 'bar_width', kind: 'number' }, - { name: 'bar_gap', kind: 'number' }, - { name: 'bar_style', kind: 'string' }, - { name: 'axis_label_color', kind: 'string' }, - { name: 'no_axis_labels', kind: 'boolean' }, - { name: 'with_axis_labels', kind: 'boolean' }, - { name: 'amplitude_scale', kind: 'number' }, - { name: 'compression', kind: 'number' }, - ], - rawValues: { - format: this.format, - width: this.width, - height: this.height, - antialiasing: this.antialiasing, - background_color: this.backgroundColor, - center_color: this.centerColor, - outer_color: this.outerColor, - style: this.style, - split_channels: this.splitChannels, - zoom: this.zoom, - pixels_per_second: this.pixelsPerSecond, - bits: this.bits, - start: this.start, - end: this.end, - colors: this.colors, - border_color: this.borderColor, - waveform_style: this.waveformStyle, - bar_width: this.barWidth, - bar_gap: this.barGap, - bar_style: this.barStyle, - axis_label_color: this.axisLabelColor, - no_axis_labels: this.noAxisLabels, - with_axis_labels: this.withAxisLabels, - amplitude_scale: this.amplitudeScale, - compression: this.compression, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - waveformed: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + format: this.format, + width: this.width, + height: this.height, + antialiasing: this.antialiasing, + background_color: this.backgroundColor, + center_color: this.centerColor, + outer_color: this.outerColor, + style: this.style, + split_channels: this.splitChannels, + zoom: this.zoom, + pixels_per_second: this.pixelsPerSecond, + bits: this.bits, + start: this.start, + end: this.end, + colors: this.colors, + border_color: this.borderColor, + waveform_style: this.waveformStyle, + bar_width: this.barWidth, + bar_gap: this.barGap, + bar_style: this.barStyle, + axis_label_color: this.axisLabelColor, + no_axis_labels: this.noAxisLabels, + with_axis_labels: this.withAxisLabels, + amplitude_scale: this.amplitudeScale, + compression: this.compression, } } } -class TextSpeakCommand extends AuthenticatedCommand { +class TextSpeakCommand extends GeneratedStandardFileIntentCommand { static override paths = [['text', 'speak']] static override usage = Command.Usage({ @@ -1964,6 +1477,13 @@ class TextSpeakCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = textSpeakCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + prompt = Option.String('--prompt', { description: 'Which text to speak. You can also set this to `null` and supply an input text file.', @@ -1990,124 +1510,18 @@ class TextSpeakCommand extends AuthenticatedCommand { 'Supply [Speech Synthesis Markup Language](https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language) instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations.\n\nPlease see the supported syntaxes for [AWS](https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html) and [GCP](https://cloud.google.com/text-to-speech/docs/ssml).', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ( - (this.inputs ?? []).length === 0 && - (this.inputBase64 ?? []).length === 0 && - this.prompt == null - ) { - this.output.error('text speak requires --input or --prompt') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotTextSpeakInstructionsSchema, - fixedValues: - (this.inputs ?? []).length > 0 - ? { - ...{ - robot: '/text/speak', - result: true, - }, - use: ':original', - } - : { - robot: '/text/speak', - result: true, - }, - fieldSpecs: [ - { name: 'prompt', kind: 'string' }, - { name: 'provider', kind: 'string' }, - { name: 'target_language', kind: 'string' }, - { name: 'voice', kind: 'string' }, - { name: 'ssml', kind: 'boolean' }, - ], - rawValues: { - prompt: this.prompt, - provider: this.provider, - target_language: this.targetLanguage, - voice: this.voice, - ssml: this.ssml, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - synthesized: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + prompt: this.prompt, + provider: this.provider, + target_language: this.targetLanguage, + voice: this.voice, + ssml: this.ssml, } } } -class VideoThumbsCommand extends AuthenticatedCommand { +class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { static override paths = [['video', 'thumbs']] static override usage = Command.Usage({ @@ -2117,6 +1531,13 @@ class VideoThumbsCommand extends AuthenticatedCommand { examples: [['Run the command', 'transloadit video thumbs --input input.mp4 --out output/']], }) + protected override readonly intentDefinition = videoThumbsCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the results to this directory', + required: true, + }) + count = Option.String('--count', { description: 'The number of thumbnails to be extracted. As some videos have incorrect durations, the actual number of thumbnails generated may be less in rare cases. The maximum number of thumbnails we currently allow is 999.\n\nThe thumbnails are taken at regular intervals, determined by dividing the video duration by the count. For example, a count of 3 will produce thumbnails at 25%, 50% and 75% through the video.\n\nTo extract thumbnails for specific timestamps, use the `offsets` parameter.', @@ -2156,118 +1577,21 @@ class VideoThumbsCommand extends AuthenticatedCommand { 'Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders.', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the results to this directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('video thumbs requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotVideoThumbsInstructionsSchema, - fixedValues: { - robot: '/video/thumbs', - result: true, - use: ':original', - }, - fieldSpecs: [ - { name: 'count', kind: 'number' }, - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'rotate', kind: 'number' }, - { name: 'input_codec', kind: 'string' }, - ], - rawValues: { - count: this.count, - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - rotate: this.rotate, - input_codec: this.inputCodec, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - thumbnailed: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + count: this.count, + format: this.format, + width: this.width, + height: this.height, + resize_strategy: this.resizeStrategy, + background: this.background, + rotate: this.rotate, + input_codec: this.inputCodec, } } } -class VideoEncodeHlsCommand extends AuthenticatedCommand { +class VideoEncodeHlsCommand extends GeneratedStandardFileIntentCommand { static override paths = [['video', 'encode-hls']] static override usage = Command.Usage({ @@ -2278,87 +1602,19 @@ class VideoEncodeHlsCommand extends AuthenticatedCommand { examples: [['Run the command', 'transloadit video encode-hls --input input.mp4 --out output/']], }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) + protected override readonly intentDefinition = videoEncodeHlsCommandDefinition - outputPath = Option.String('--out,-o', { + override outputPath = Option.String('--out,-o', { description: 'Write the results to this directory', required: true, }) - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('video encode-hls requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - template: 'builtin/encode-hls-video@latest', - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) - } + protected override getIntentRawValues(): Record { + return {} } } -class FileCompressCommand extends AuthenticatedCommand { +class FileCompressCommand extends GeneratedBundledFileIntentCommand { static override paths = [['file', 'compress']] static override usage = Command.Usage({ @@ -2370,6 +1626,13 @@ class FileCompressCommand extends AuthenticatedCommand { ], }) + protected override readonly intentDefinition = fileCompressCommandDefinition + + override outputPath = Option.String('--out,-o', { + description: 'Write the result to this path or directory', + required: true, + }) + format = Option.String('--format', { description: 'The format of the archive to be created. Supported values are `"tar"` and `"zip"`.\n\nNote that `"tar"` without setting `gzip` to `true` results in an archive that\'s not compressed in any way.', @@ -2399,92 +1662,19 @@ class FileCompressCommand extends AuthenticatedCommand { description: 'The name of the archive file to be created (without the file extension).', }) - inputs = Option.Array('--input,-i', { - description: 'Provide one or more input paths, directories, URLs, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) - - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('file compress requires --input or --input-base64') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - try { - const step = parseIntentStep({ - schema: robotFileCompressInstructionsSchema, - fixedValues: { - robot: '/file/compress', - result: true, - use: { - steps: [':original'], - bundle_steps: true, - }, - }, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'gzip', kind: 'boolean' }, - { name: 'password', kind: 'string' }, - { name: 'compression_level', kind: 'number' }, - { name: 'file_layout', kind: 'string' }, - { name: 'archive_name', kind: 'string' }, - ], - rawValues: { - format: this.format, - gzip: this.gzip, - password: this.password, - compression_level: this.compressionLevel, - file_layout: this.fileLayout, - archive_name: this.archiveName, - }, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - compressed: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'file', - recursive: this.recursive, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: true, - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override getIntentRawValues(): Record { + return { + format: this.format, + gzip: this.gzip, + password: this.password, + compression_level: this.compressionLevel, + file_layout: this.fileLayout, + archive_name: this.archiveName, } } } -class FileDecompressCommand extends AuthenticatedCommand { +class FileDecompressCommand extends GeneratedStandardFileIntentCommand { static override paths = [['file', 'decompress']] static override usage = Command.Usage({ @@ -2494,96 +1684,15 @@ class FileDecompressCommand extends AuthenticatedCommand { examples: [['Run the command', 'transloadit file decompress --input input.file --out output/']], }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) - - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) + protected override readonly intentDefinition = fileDecompressCommandDefinition - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) - - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) - - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) - - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) - - outputPath = Option.String('--out,-o', { + override outputPath = Option.String('--out,-o', { description: 'Write the results to this directory', required: true, }) - protected async run(): Promise { - if ((this.inputs ?? []).length === 0 && (this.inputBase64 ?? []).length === 0) { - this.output.error('file decompress requires --input or --input-base64') - return 1 - } - - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } - - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) - - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - try { - const step = parseIntentStep({ - schema: robotFileDecompressInstructionsSchema, - fixedValues: { - robot: '/file/decompress', - result: true, - use: ':original', - }, - fieldSpecs: [], - rawValues: {}, - }) - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - stepsData: { - decompressed: step, - }, - inputs: preparedInputs.inputs, - output: this.outputPath, - outputMode: 'directory', - recursive: this.recursive, - watch: this.watch, - del: this.deleteAfterProcessing, - reprocessStale: this.reprocessStale, - singleAssembly: this.singleAssembly, - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) - } + protected override getIntentRawValues(): Record { + return {} } } diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index 7ae70749..d8b5c755 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -58,180 +58,227 @@ import { meta as robotVideoThumbsMeta, } from '../alphalib/types/robots/video-thumbs.ts' -export type IntentInputMode = 'local-files' | 'none' | 'remote-url' +export type IntentInputMode = 'local-files' | 'none' export type IntentOutputMode = 'directory' | 'file' -export interface RobotIntentDefinition { +interface IntentSchemaDefinition { meta: RobotMetaInput - robot: string schema: z.AnyZodObject schemaImportName: string schemaImportPath: string } -export interface RobotIntentCatalogEntry { - kind: 'robot' - defaultSingleAssembly?: boolean - inputMode?: Exclude +interface IntentBaseDefinition { outputMode?: IntentOutputMode paths?: string[] - robot: keyof typeof robotIntentDefinitions } -export interface TemplateIntentCatalogEntry { +export interface RobotIntentDefinition extends IntentBaseDefinition, IntentSchemaDefinition { + defaultSingleAssembly?: boolean + inputMode?: IntentInputMode + kind: 'robot' + robot: string +} + +export interface TemplateIntentDefinition extends IntentBaseDefinition { kind: 'template' - outputMode?: IntentOutputMode paths: string[] templateId: string } -export interface RecipeIntentCatalogEntry { - kind: 'recipe' - recipe: keyof typeof intentRecipeDefinitions +export type IntentDefinition = RobotIntentDefinition | TemplateIntentDefinition + +const commandPathAliases = new Map([ + ['autorotate', 'auto-rotate'], + ['bgremove', 'remove-background'], +]) + +function defineRobotIntent(definition: RobotIntentDefinition): RobotIntentDefinition { + return definition } -export type IntentCatalogEntry = - | RecipeIntentCatalogEntry - | RobotIntentCatalogEntry - | TemplateIntentCatalogEntry - -export interface IntentRecipeDefinition { - description: string - details: string - examples: Array<[string, string]> - inputMode: 'remote-url' - outputDescription: string - outputRequired: boolean - paths: string[] - resultStepName: string - schema: z.AnyZodObject - schemaImportName: string - schemaImportPath: string - summary: string +function defineTemplateIntent(definition: TemplateIntentDefinition): TemplateIntentDefinition { + return definition } -export const robotIntentDefinitions = { - '/audio/waveform': { - robot: '/audio/waveform', - meta: robotAudioWaveformMeta, - schema: robotAudioWaveformInstructionsSchema, - schemaImportName: 'robotAudioWaveformInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/audio-waveform.ts', - }, - '/document/autorotate': { - robot: '/document/autorotate', - meta: robotDocumentAutorotateMeta, - schema: robotDocumentAutorotateInstructionsSchema, - schemaImportName: 'robotDocumentAutorotateInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-autorotate.ts', - }, - '/document/convert': { - robot: '/document/convert', - meta: robotDocumentConvertMeta, - schema: robotDocumentConvertInstructionsSchema, - schemaImportName: 'robotDocumentConvertInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-convert.ts', - }, - '/document/optimize': { - robot: '/document/optimize', - meta: robotDocumentOptimizeMeta, - schema: robotDocumentOptimizeInstructionsSchema, - schemaImportName: 'robotDocumentOptimizeInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-optimize.ts', - }, - '/document/thumbs': { - robot: '/document/thumbs', - meta: robotDocumentThumbsMeta, - schema: robotDocumentThumbsInstructionsSchema, - schemaImportName: 'robotDocumentThumbsInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-thumbs.ts', - }, - '/file/compress': { - robot: '/file/compress', - meta: robotFileCompressMeta, - schema: robotFileCompressInstructionsSchema, - schemaImportName: 'robotFileCompressInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/file-compress.ts', - }, - '/file/decompress': { - robot: '/file/decompress', - meta: robotFileDecompressMeta, - schema: robotFileDecompressInstructionsSchema, - schemaImportName: 'robotFileDecompressInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/file-decompress.ts', - }, - '/file/preview': { +export function getIntentCatalogKey(definition: IntentDefinition): string { + if (definition.kind === 'robot') { + return definition.robot + } + + return definition.templateId +} + +export function getIntentPaths(definition: IntentDefinition): string[] { + if (definition.paths != null) { + return definition.paths + } + + if (definition.kind !== 'robot') { + throw new Error(`Intent definition ${getIntentCatalogKey(definition)} is missing paths`) + } + + const segments = definition.robot.split('/').filter(Boolean) + const [group, action] = segments + if (group == null || action == null) { + throw new Error(`Could not infer command path from robot "${definition.robot}"`) + } + + return [group, commandPathAliases.get(action) ?? action] +} + +export function getIntentCommandLabel(definition: IntentDefinition): string { + return getIntentPaths(definition).join(' ') +} + +export function getIntentResultStepName(definition: IntentDefinition): string | null { + if (definition.kind !== 'robot') { + return null + } + + const paths = getIntentPaths(definition) + const action = paths[paths.length - 1] + if (action == null) { + throw new Error(`Intent definition ${definition.robot} has no action path`) + } + + return action.replaceAll('-', '_') +} + +export function findIntentDefinitionByPaths( + paths: readonly string[], +): IntentDefinition | undefined { + return intentCatalog.find((definition) => { + const definitionPaths = getIntentPaths(definition) + return ( + definitionPaths.length === paths.length && + definitionPaths.every((part, index) => part === paths[index]) + ) + }) +} + +export const intentCatalog = [ + defineRobotIntent({ + kind: 'robot', + robot: '/image/generate', + meta: robotImageGenerateMeta, + schema: robotImageGenerateInstructionsSchema, + schemaImportName: 'robotImageGenerateInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/image-generate.ts', + }), + defineRobotIntent({ + kind: 'robot', robot: '/file/preview', + paths: ['preview', 'generate'], meta: robotFilePreviewMeta, schema: robotFilePreviewInstructionsSchema, schemaImportName: 'robotFilePreviewInstructionsSchema', schemaImportPath: '../../alphalib/types/robots/file-preview.ts', - }, - '/image/bgremove': { + }), + defineRobotIntent({ + kind: 'robot', robot: '/image/bgremove', meta: robotImageBgremoveMeta, schema: robotImageBgremoveInstructionsSchema, schemaImportName: 'robotImageBgremoveInstructionsSchema', schemaImportPath: '../../alphalib/types/robots/image-bgremove.ts', - }, - '/image/generate': { - robot: '/image/generate', - meta: robotImageGenerateMeta, - schema: robotImageGenerateInstructionsSchema, - schemaImportName: 'robotImageGenerateInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/image-generate.ts', - }, - '/image/optimize': { + }), + defineRobotIntent({ + kind: 'robot', robot: '/image/optimize', meta: robotImageOptimizeMeta, schema: robotImageOptimizeInstructionsSchema, schemaImportName: 'robotImageOptimizeInstructionsSchema', schemaImportPath: '../../alphalib/types/robots/image-optimize.ts', - }, - '/image/resize': { + }), + defineRobotIntent({ + kind: 'robot', robot: '/image/resize', meta: robotImageResizeMeta, schema: robotImageResizeInstructionsSchema, schemaImportName: 'robotImageResizeInstructionsSchema', schemaImportPath: '../../alphalib/types/robots/image-resize.ts', - }, - '/text/speak': { + }), + defineRobotIntent({ + kind: 'robot', + robot: '/document/convert', + meta: robotDocumentConvertMeta, + schema: robotDocumentConvertInstructionsSchema, + schemaImportName: 'robotDocumentConvertInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-convert.ts', + }), + defineRobotIntent({ + kind: 'robot', + robot: '/document/optimize', + meta: robotDocumentOptimizeMeta, + schema: robotDocumentOptimizeInstructionsSchema, + schemaImportName: 'robotDocumentOptimizeInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-optimize.ts', + }), + defineRobotIntent({ + kind: 'robot', + robot: '/document/autorotate', + meta: robotDocumentAutorotateMeta, + schema: robotDocumentAutorotateInstructionsSchema, + schemaImportName: 'robotDocumentAutorotateInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-autorotate.ts', + }), + defineRobotIntent({ + kind: 'robot', + robot: '/document/thumbs', + outputMode: 'directory', + meta: robotDocumentThumbsMeta, + schema: robotDocumentThumbsInstructionsSchema, + schemaImportName: 'robotDocumentThumbsInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/document-thumbs.ts', + }), + defineRobotIntent({ + kind: 'robot', + robot: '/audio/waveform', + meta: robotAudioWaveformMeta, + schema: robotAudioWaveformInstructionsSchema, + schemaImportName: 'robotAudioWaveformInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/audio-waveform.ts', + }), + defineRobotIntent({ + kind: 'robot', robot: '/text/speak', meta: robotTextSpeakMeta, schema: robotTextSpeakInstructionsSchema, schemaImportName: 'robotTextSpeakInstructionsSchema', schemaImportPath: '../../alphalib/types/robots/text-speak.ts', - }, - '/video/thumbs': { + }), + defineRobotIntent({ + kind: 'robot', robot: '/video/thumbs', + outputMode: 'directory', meta: robotVideoThumbsMeta, schema: robotVideoThumbsInstructionsSchema, schemaImportName: 'robotVideoThumbsInstructionsSchema', schemaImportPath: '../../alphalib/types/robots/video-thumbs.ts', - }, -} satisfies Record - -export const intentRecipeDefinitions = {} satisfies Record - -export const intentCatalog = [ - { kind: 'robot', robot: '/image/generate' }, - { kind: 'robot', robot: '/file/preview', paths: ['preview', 'generate'] }, - { kind: 'robot', robot: '/image/bgremove' }, - { kind: 'robot', robot: '/image/optimize' }, - { kind: 'robot', robot: '/image/resize' }, - { kind: 'robot', robot: '/document/convert' }, - { kind: 'robot', robot: '/document/optimize' }, - { kind: 'robot', robot: '/document/autorotate' }, - { kind: 'robot', robot: '/document/thumbs', outputMode: 'directory' }, - { kind: 'robot', robot: '/audio/waveform' }, - { kind: 'robot', robot: '/text/speak' }, - { kind: 'robot', robot: '/video/thumbs', outputMode: 'directory' }, - { + }), + defineTemplateIntent({ kind: 'template', templateId: 'builtin/encode-hls-video@latest', paths: ['video', 'encode-hls'], outputMode: 'directory', - }, - { kind: 'robot', robot: '/file/compress', defaultSingleAssembly: true }, - { kind: 'robot', robot: '/file/decompress', outputMode: 'directory' }, -] satisfies IntentCatalogEntry[] + }), + defineRobotIntent({ + kind: 'robot', + robot: '/file/compress', + defaultSingleAssembly: true, + meta: robotFileCompressMeta, + schema: robotFileCompressInstructionsSchema, + schemaImportName: 'robotFileCompressInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/file-compress.ts', + }), + defineRobotIntent({ + kind: 'robot', + robot: '/file/decompress', + outputMode: 'directory', + meta: robotFileDecompressMeta, + schema: robotFileDecompressInstructionsSchema, + schemaImportName: 'robotFileDecompressInstructionsSchema', + schemaImportPath: '../../alphalib/types/robots/file-decompress.ts', + }), +] satisfies IntentDefinition[] diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index ee16a06d..2ac59aad 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -1,7 +1,12 @@ import { basename } from 'node:path' +import { Option } from 'clipanion' +import * as t from 'typanion' import type { z } from 'zod' import { prepareInputFiles } from '../inputFiles.ts' +import type { AssembliesCreateOptions } from './commands/assemblies.ts' +import * as assembliesCommands from './commands/assemblies.ts' +import { AuthenticatedCommand } from './commands/BaseCommand.ts' export type IntentFieldKind = 'auto' | 'boolean' | 'number' | 'string' @@ -16,6 +21,36 @@ export interface PreparedIntentInputs { inputs: string[] } +export interface IntentSingleStepExecutionDefinition { + attachUseWhenInputsProvided?: boolean + fieldSpecs: readonly IntentFieldSpec[] + fixedValues: Record + kind: 'single-step' + resultStepName: string + schema: z.AnyZodObject +} + +export interface IntentTemplateExecutionDefinition { + kind: 'template' + templateId: string +} + +export type IntentFileExecutionDefinition = + | IntentSingleStepExecutionDefinition + | IntentTemplateExecutionDefinition + +export interface IntentFileCommandDefinition { + commandLabel: string + execution: IntentFileExecutionDefinition + outputMode?: 'directory' | 'file' + requiredFieldForInputless?: string +} + +export interface IntentNoInputCommandDefinition { + execution: IntentSingleStepExecutionDefinition + outputMode?: 'directory' | 'file' +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value) @@ -203,3 +238,257 @@ export function parseIntentStep({ return normalizedInput as z.input } + +function resolveSingleStepFixedValues( + execution: IntentSingleStepExecutionDefinition, + hasInputs: boolean, +): Record { + if (!hasInputs || execution.attachUseWhenInputsProvided !== true) { + return execution.fixedValues + } + + return { + ...execution.fixedValues, + use: ':original', + } +} + +function createSingleStep( + execution: IntentSingleStepExecutionDefinition, + rawValues: Record, + hasInputs: boolean, +): z.input { + return parseIntentStep({ + schema: execution.schema, + fixedValues: resolveSingleStepFixedValues(execution, hasInputs), + fieldSpecs: execution.fieldSpecs, + rawValues, + }) +} + +function requiresLocalInput( + requiredFieldForInputless: string | undefined, + rawValues: Record, +): boolean { + if (requiredFieldForInputless == null) { + return true + } + + return rawValues[requiredFieldForInputless] == null +} + +async function executeFileIntentCommand({ + client, + definition, + output, + outputPath, + rawValues, + createOptions, +}: { + client: AuthenticatedCommand['client'] + createOptions: Omit + definition: IntentFileCommandDefinition + output: AuthenticatedCommand['output'] + outputPath: string + rawValues: Record +}): Promise { + if (definition.execution.kind === 'template') { + const { hasFailures } = await assembliesCommands.create(output, client, { + ...createOptions, + template: definition.execution.templateId, + output: outputPath, + outputMode: definition.outputMode, + }) + return hasFailures ? 1 : undefined + } + + const step = createSingleStep(definition.execution, rawValues, createOptions.inputs.length > 0) + const { hasFailures } = await assembliesCommands.create(output, client, { + ...createOptions, + output: outputPath, + outputMode: definition.outputMode, + stepsData: { + [definition.execution.resultStepName]: step, + } as AssembliesCreateOptions['stepsData'], + }) + return hasFailures ? 1 : undefined +} + +abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { + outputPath = Option.String('--out,-o', { + description: 'Write the result to this path', + required: true, + }) + + protected abstract getIntentRawValues(): Record +} + +export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentCommandBase { + protected abstract readonly intentDefinition: IntentNoInputCommandDefinition + + protected override async run(): Promise { + const step = createSingleStep(this.intentDefinition.execution, this.getIntentRawValues(), false) + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + inputs: [], + output: this.outputPath, + outputMode: this.intentDefinition.outputMode, + stepsData: { + [this.intentDefinition.execution.resultStepName]: step, + } as AssembliesCreateOptions['stepsData'], + }) + + return hasFailures ? 1 : undefined + } +} + +abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase { + inputs = Option.Array('--input,-i', { + description: 'Provide an input path, directory, URL, or - for stdin', + }) + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', + }) + + recursive = Option.Boolean('--recursive,-r', false, { + description: 'Enumerate input directories recursively', + }) + + deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { + description: 'Delete input files after they are processed', + }) + + reprocessStale = Option.Boolean('--reprocess-stale', false, { + description: 'Process inputs even if output is newer', + }) + + protected abstract readonly intentDefinition: IntentFileCommandDefinition + + protected async prepareInputs(): Promise { + return await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], + }) + } + + protected getCreateOptions( + inputs: string[], + ): Omit { + return { + del: this.deleteAfterProcessing, + inputs, + reprocessStale: this.reprocessStale, + recursive: this.recursive, + } + } + + protected validateInputPresence( + rawValues: Record, + ): number | undefined { + const inputCount = (this.inputs ?? []).length + (this.inputBase64 ?? []).length + if (inputCount !== 0) { + return undefined + } + + if (!requiresLocalInput(this.intentDefinition.requiredFieldForInputless, rawValues)) { + return undefined + } + + if (this.intentDefinition.requiredFieldForInputless == null) { + this.output.error(`${this.intentDefinition.commandLabel} requires --input or --input-base64`) + return 1 + } + + this.output.error( + `${this.intentDefinition.commandLabel} requires --input or --${this.intentDefinition.requiredFieldForInputless.replaceAll('_', '-')}`, + ) + return 1 + } + + protected async runWithPreparedInputs( + rawValues: Record, + preparedInputs: PreparedIntentInputs, + ): Promise { + try { + return await executeFileIntentCommand({ + client: this.client, + createOptions: this.getCreateOptions(preparedInputs.inputs), + definition: this.intentDefinition, + output: this.output, + outputPath: this.outputPath, + rawValues, + }) + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } + } +} + +export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIntentCommandBase { + watch = Option.Boolean('--watch,-w', false, { + description: 'Watch inputs for changes', + }) + + singleAssembly = Option.Boolean('--single-assembly', false, { + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + + concurrency = Option.String('--concurrency,-c', { + description: 'Maximum number of concurrent assemblies (default: 5)', + validator: t.isNumber(), + }) + + protected override getCreateOptions( + inputs: string[], + ): Omit { + return { + ...super.getCreateOptions(inputs), + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + singleAssembly: this.singleAssembly, + watch: this.watch, + } + } + + protected override async run(): Promise { + const rawValues = this.getIntentRawValues() + const validationError = this.validateInputPresence(rawValues) + if (validationError != null) { + return validationError + } + + if (this.singleAssembly && this.watch) { + this.output.error('--single-assembly cannot be used with --watch') + return 1 + } + + const preparedInputs = await this.prepareInputs() + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } + + return await this.runWithPreparedInputs(rawValues, preparedInputs) + } +} + +export abstract class GeneratedBundledFileIntentCommand extends GeneratedFileIntentCommandBase { + protected override getCreateOptions( + inputs: string[], + ): Omit { + return { + ...super.getCreateOptions(inputs), + singleAssembly: true, + } + } + + protected override async run(): Promise { + const rawValues = this.getIntentRawValues() + const validationError = this.validateInputPresence(rawValues) + if (validationError != null) { + return validationError + } + + const preparedInputs = await this.prepareInputs() + return await this.runWithPreparedInputs(rawValues, preparedInputs) + } +} diff --git a/packages/node/src/cli/intentSmokeCases.ts b/packages/node/src/cli/intentSmokeCases.ts new file mode 100644 index 00000000..4687bc27 --- /dev/null +++ b/packages/node/src/cli/intentSmokeCases.ts @@ -0,0 +1,106 @@ +import { getIntentCatalogKey, getIntentPaths, intentCatalog } from './intentCommandSpecs.ts' + +export interface IntentSmokeCase { + args: string[] + key: string + outputPath: string + paths: string[] + verifier: string +} + +const intentSmokeOverrides: Record> = { + '/audio/waveform': { + args: ['--input', '@fixture/input.mp3'], + outputPath: 'audio-waveform.png', + verifier: 'png', + }, + '/document/autorotate': { + args: ['--input', '@fixture/input.pdf'], + outputPath: 'document-auto-rotate.pdf', + verifier: 'pdf', + }, + '/document/convert': { + args: ['--input', '@fixture/input.txt', '--format', 'pdf'], + outputPath: 'document-convert.pdf', + verifier: 'pdf', + }, + '/document/optimize': { + args: ['--input', '@fixture/input.pdf'], + outputPath: 'document-optimize.pdf', + verifier: 'pdf', + }, + '/document/thumbs': { + args: ['--input', '@fixture/input.pdf'], + outputPath: 'document-thumbs', + verifier: 'document-thumbs', + }, + '/file/compress': { + args: ['--input', '@fixture/input.txt', '--format', 'zip'], + outputPath: 'file-compress.zip', + verifier: 'zip', + }, + '/file/decompress': { + args: ['--input', '@fixture/input.zip'], + outputPath: 'file-decompress', + verifier: 'file-decompress', + }, + '/file/preview': { + args: ['--input', '@preview-url', '--width', '300'], + outputPath: 'preview-generate.png', + verifier: 'png', + }, + '/image/bgremove': { + args: ['--input', '@fixture/input.jpg'], + outputPath: 'image-remove-background.png', + verifier: 'png', + }, + '/image/generate': { + args: [ + '--prompt', + 'A small red bicycle on a cream background, studio lighting', + '--model', + 'google/nano-banana', + ], + outputPath: 'image-generate.png', + verifier: 'png', + }, + '/image/optimize': { + args: ['--input', '@fixture/input.jpg'], + outputPath: 'image-optimize.jpg', + verifier: 'jpeg', + }, + '/image/resize': { + args: ['--input', '@fixture/input.jpg', '--width', '200'], + outputPath: 'image-resize.jpg', + verifier: 'jpeg', + }, + '/text/speak': { + args: ['--prompt', 'Hello from the Transloadit Node CLI intents test.', '--provider', 'aws'], + outputPath: 'text-speak.mp3', + verifier: 'mp3', + }, + '/video/thumbs': { + args: ['--input', '@fixture/input.mp4'], + outputPath: 'video-thumbs', + verifier: 'video-thumbs', + }, + 'builtin/encode-hls-video@latest': { + args: ['--input', '@fixture/input.mp4'], + outputPath: 'video-encode-hls', + verifier: 'video-encode-hls', + }, +} + +export const intentSmokeCases = intentCatalog.map((intent) => { + const key = getIntentCatalogKey(intent) + const smokeCase = intentSmokeOverrides[key] + if (smokeCase == null) { + throw new Error(`Missing smoke-case definition for ${key}`) + } + + return { + ...smokeCase, + key, + paths: getIntentPaths(intent), + } +}) satisfies IntentSmokeCase[] diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 7417a748..df49c2d6 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as assembliesCommands from '../../../src/cli/commands/assemblies.ts' import { intentCommands } from '../../../src/cli/commands/generated-intents.ts' +import { + findIntentDefinitionByPaths, + getIntentPaths, + getIntentResultStepName, + intentCatalog, +} from '../../../src/cli/intentCommandSpecs.ts' +import { intentSmokeCases } from '../../../src/cli/intentSmokeCases.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' @@ -25,6 +32,20 @@ function getIntentCommand(paths: string[]): (typeof intentCommands)[number] { return command } +function getIntentStepName(paths: string[]): string { + const definition = findIntentDefinitionByPaths(paths) + if (definition == null || definition.kind !== 'robot') { + throw new Error(`No robot intent definition found for ${paths.join(' ')}`) + } + + const stepName = getIntentResultStepName(definition) + if (stepName == null) { + throw new Error(`No intent result step name found for ${paths.join(' ')}`) + } + + return stepName +} + afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() @@ -65,7 +86,7 @@ describe('intent commands', () => { inputs: [], output: 'generated.png', stepsData: { - generated_image: expect.objectContaining({ + [getIntentStepName(['image', 'generate'])]: expect.objectContaining({ robot: '/image/generate', result: true, prompt: 'A red bicycle in a studio', @@ -111,7 +132,7 @@ describe('intent commands', () => { inputs: ['document.pdf'], output: 'preview.jpg', stepsData: { - preview: expect.objectContaining({ + [getIntentStepName(['preview', 'generate'])]: expect.objectContaining({ robot: '/file/preview', result: true, use: ':original', @@ -152,7 +173,7 @@ describe('intent commands', () => { expect.objectContaining({ inputs: [expect.stringContaining('transloadit-input-')], stepsData: { - preview: expect.objectContaining({ + [getIntentStepName(['preview', 'generate'])]: expect.objectContaining({ robot: '/file/preview', use: ':original', }), @@ -190,7 +211,7 @@ describe('intent commands', () => { expect.objectContaining({ inputs: [expect.stringContaining('transloadit-input-')], stepsData: { - converted: expect.objectContaining({ + [getIntentStepName(['document', 'convert'])]: expect.objectContaining({ robot: '/document/convert', use: ':original', format: 'pdf', @@ -260,7 +281,7 @@ describe('intent commands', () => { inputs: [], output: 'hello.mp3', stepsData: { - synthesized: expect.objectContaining({ + [getIntentStepName(['text', 'speak'])]: expect.objectContaining({ robot: '/text/speak', result: true, prompt: 'Hello world', @@ -303,7 +324,7 @@ describe('intent commands', () => { inputs: [], output: 'hello.mp3', stepsData: { - synthesized: { + [getIntentStepName(['text', 'speak'])]: { robot: '/text/speak', result: true, prompt: 'Hello from a prompt', @@ -344,7 +365,7 @@ describe('intent commands', () => { inputs: ['article.txt'], output: 'hello.mp3', stepsData: { - synthesized: { + [getIntentStepName(['text', 'speak'])]: { robot: '/text/speak', result: true, use: ':original', @@ -376,7 +397,7 @@ describe('intent commands', () => { inputs: ['podcast.mp3'], output: 'waveform.png', stepsData: { - waveformed: { + [getIntentStepName(['audio', 'waveform'])]: { robot: '/audio/waveform', result: true, use: ':original', @@ -416,7 +437,7 @@ describe('intent commands', () => { inputs: ['song.mp3'], output: 'waveform.png', stepsData: { - waveformed: expect.objectContaining({ + [getIntentStepName(['audio', 'waveform'])]: expect.objectContaining({ robot: '/audio/waveform', result: true, use: ':original', @@ -471,7 +492,7 @@ describe('intent commands', () => { expect.anything(), expect.objectContaining({ stepsData: { - thumbnailed: expect.objectContaining({ + [getIntentStepName(['video', 'thumbs'])]: expect.objectContaining({ robot: '/video/thumbs', rotate: 90, }), @@ -508,7 +529,7 @@ describe('intent commands', () => { expect.anything(), expect.objectContaining({ stepsData: { - resized: expect.objectContaining({ + [getIntentStepName(['image', 'resize'])]: expect.objectContaining({ robot: '/image/resize', rotation: 90, }), @@ -545,7 +566,7 @@ describe('intent commands', () => { expect.anything(), expect.objectContaining({ stepsData: { - waveformed: expect.objectContaining({ + [getIntentStepName(['audio', 'waveform'])]: expect.objectContaining({ robot: '/audio/waveform', antialiasing: 1, }), @@ -587,7 +608,7 @@ describe('intent commands', () => { output: 'assets.zip', singleAssembly: true, stepsData: { - compressed: expect.objectContaining({ + [getIntentStepName(['file', 'compress'])]: expect.objectContaining({ robot: '/file/compress', result: true, format: 'zip', @@ -621,7 +642,7 @@ describe('intent commands', () => { expect.anything(), expect.objectContaining({ stepsData: { - compressed: { + [getIntentStepName(['file', 'compress'])]: { robot: '/file/compress', result: true, format: 'zip', @@ -654,7 +675,7 @@ describe('intent commands', () => { expect.anything(), expect.objectContaining({ stepsData: { - thumbnailed: { + [getIntentStepName(['video', 'thumbs'])]: { robot: '/video/thumbs', result: true, use: ':original', @@ -672,4 +693,13 @@ describe('intent commands', () => { ['Run the command', expect.stringContaining('--provider')], ]) }) + + it('keeps the catalog, generated commands, and smoke cases in sync', () => { + const catalogPaths = intentCatalog.map((definition) => getIntentPaths(definition).join(' ')) + const generatedPaths = intentCommands.map((command) => command.paths[0]?.join(' ')) + const smokePaths = intentSmokeCases.map((smokeCase) => smokeCase.paths.join(' ')) + + expect([...catalogPaths].sort()).toEqual([...generatedPaths].sort()) + expect([...catalogPaths].sort()).toEqual([...smokePaths].sort()) + }) }) From c81322d333f941bc53db1bfa6bdc86cc41e1234a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 16:27:09 +0200 Subject: [PATCH 15/44] chore(transloadit): refresh parity fingerprint --- docs/fingerprint/transloadit-baseline.json | 103 +++++++++++++-------- 1 file changed, 64 insertions(+), 39 deletions(-) diff --git a/docs/fingerprint/transloadit-baseline.json b/docs/fingerprint/transloadit-baseline.json index 8b980d1c..c07d25d9 100644 --- a/docs/fingerprint/transloadit-baseline.json +++ b/docs/fingerprint/transloadit-baseline.json @@ -2,8 +2,8 @@ "packageDir": "/Users/kvz/code/node-sdk/packages/transloadit", "tarball": { "filename": "transloadit-4.7.5.tgz", - "sizeBytes": 1319165, - "sha256": "e398ad0369c894bf96433646ca1831a45b33b733905cdd30fd8d031573d8f25b" + "sizeBytes": 1321366, + "sha256": "4851dea426769890fb4f6afa664c6a4d561d16d64f6cf11dc72e69ef25481028" }, "packageJson": { "name": "transloadit", @@ -48,8 +48,8 @@ }, { "path": "dist/cli/commands/assemblies.js", - "sizeBytes": 51973, - "sha256": "e9ac5395852192082f1a19cf1c0e33b0a3b60c6a6cf3df76de4e73fb53703f6a" + "sizeBytes": 51785, + "sha256": "7c2279e65fe8bcc4221da04185d4f86128dad847b475471f3e6f51a340446123" }, { "path": "dist/alphalib/types/assembliesGet.js", @@ -328,8 +328,8 @@ }, { "path": "dist/cli/commands/generated-intents.js", - "sizeBytes": 117625, - "sha256": "cfb0e934d6e426151f51d1f28519a2dcaafb4c68c0ebae9a8959f96999d2dfcf" + "sizeBytes": 80156, + "sha256": "78b4ef99a8190fc734bc50fd23b1895ab58a7d0899c67d12c58a5de118145615" }, { "path": "dist/alphalib/types/robots/google-import.js", @@ -413,13 +413,18 @@ }, { "path": "dist/cli/intentCommandSpecs.js", - "sizeBytes": 7199, - "sha256": "12a812c6efd4697b45053d9d7a60b2cf4c87c4aa3a497f493b21c53f1affe0d5" + "sizeBytes": 8571, + "sha256": "51be45b70ed24ee4503e2650d1e7a0813afea58d8988fbf533277b7fd13116df" }, { "path": "dist/cli/intentRuntime.js", - "sizeBytes": 4416, - "sha256": "06cfff14909b48c57dd0aec481b0d45340441dd1a59de3348b9b23a45cfc0415" + "sizeBytes": 11592, + "sha256": "67306e344a413251a4f3be40fcc59c9f9cfd2a3a7fc39f1613ae12e75f9033d4" + }, + { + "path": "dist/cli/intentSmokeCases.js", + "sizeBytes": 3072, + "sha256": "01e0f5f7d57c1fbb697b9ce1cd599b375cbe2b0414c565f2e6e7a957d470df9d" }, { "path": "dist/lintAssemblyInput.js", @@ -769,12 +774,12 @@ { "path": "dist/cli/commands/assemblies.d.ts.map", "sizeBytes": 3877, - "sha256": "a7780be849e81aaa345c859f224462a6d36faefdd2a0f8ea91b8f94a505437ef" + "sha256": "34168a6c15c65795f807f296f3245b7b57ea869d6a450a87f1c81462ee0f81b5" }, { "path": "dist/cli/commands/assemblies.js.map", - "sizeBytes": 46288, - "sha256": "cf8b96797224c7dc9724bf93100a778967f92288e0a0ae1d3a1a9028e7b50386" + "sizeBytes": 46773, + "sha256": "f2acb3a132a46d27f42be7e63a77fb3749833a90d3bbce5d4c64c13965363893" }, { "path": "dist/alphalib/types/assembliesGet.d.ts.map", @@ -1328,13 +1333,13 @@ }, { "path": "dist/cli/commands/generated-intents.d.ts.map", - "sizeBytes": 6612, - "sha256": "9335d244c8e1414ad5b4186fc4b7bc86cf10c33da7dbd6521de5ae4d8c7b4108" + "sizeBytes": 9477, + "sha256": "8a092bbeec0210a9a95e857e59adc4885d1f5db3898a35f2963b704f1f1c3303" }, { "path": "dist/cli/commands/generated-intents.js.map", - "sizeBytes": 66461, - "sha256": "e4c0b89607638191017a161b2c253a57663ebaff81800c6c4b33618a1129e75b" + "sizeBytes": 38113, + "sha256": "9aafe6bd60cc360d4dd6a05adeda0c1e97ae89a9e0661f3ffc065d2ab4f1de0a" }, { "path": "dist/alphalib/types/robots/google-import.d.ts.map", @@ -1498,23 +1503,33 @@ }, { "path": "dist/cli/intentCommandSpecs.d.ts.map", - "sizeBytes": 5804, - "sha256": "8179d0aba494de60e0b90dd4d880c1875e3df3053ff11ed76b9cd68de1245f9d" + "sizeBytes": 1251, + "sha256": "aa4ca0d044e7fa4da9511e5741c0326e4f48b73e0fa177b22700b0ea1dc280cd" }, { "path": "dist/cli/intentCommandSpecs.js.map", - "sizeBytes": 4171, - "sha256": "1e54470578a751319fbac64a4d91a8013dfb6137952aa26f18c7e2f8bfef9fb6" + "sizeBytes": 5562, + "sha256": "0796aa6c0980187fd622040be588d299a3c51671bf45c1cf36bda74b8097bb7c" }, { "path": "dist/cli/intentRuntime.d.ts.map", - "sizeBytes": 950, - "sha256": "fb6f5fe96ddb695919494e58741d33c251e7d1b458874a604edf65988aa9bab9" + "sizeBytes": 2925, + "sha256": "5009bb93c79fc17697ac4a4ea16a716fef038f5e508b5a7551108abe3758c35f" }, { "path": "dist/cli/intentRuntime.js.map", - "sizeBytes": 4469, - "sha256": "3dd4065e8c0a72148f15e044af0565fafceaee7b55a270c0d5f6d5e8f214eb79" + "sizeBytes": 10400, + "sha256": "04832c4b21892b55b0a53cddada09e2c543bdfe6b7c45bb31fef87b166b4d138" + }, + { + "path": "dist/cli/intentSmokeCases.d.ts.map", + "sizeBytes": 369, + "sha256": "5fbbb3c25c53e55cc34ba0be87dcacd00373a6cc2ef774dedf58d1354998fbf3" + }, + { + "path": "dist/cli/intentSmokeCases.js.map", + "sizeBytes": 2362, + "sha256": "79eba061880bea4639cf758199a81f9ba4be20276b463f5d488d5a8054a0659f" }, { "path": "dist/lintAssemblyInput.d.ts.map", @@ -2158,8 +2173,8 @@ }, { "path": "src/cli/commands/assemblies.ts", - "sizeBytes": 51861, - "sha256": "2ec97034f025676083dca02198437695a8a315f3c33eb0a12e9d28ffacd0fe8c" + "sizeBytes": 52644, + "sha256": "06ea627a1d0d29dd8ca853b0a535ee5ab728d56fbeacbe4b65b81c6b90569900" }, { "path": "dist/alphalib/types/assembliesGet.d.ts", @@ -2713,13 +2728,13 @@ }, { "path": "dist/cli/commands/generated-intents.d.ts", - "sizeBytes": 12769, - "sha256": "9b9c0bc70c99b952bae43dc7198bd312f4a55a194d9cfac201fff41499f4ec98" + "sizeBytes": 261996, + "sha256": "4e1c9ba6760c8e0f237cafbbcbfedf799fac73d3e1aa1f9e8f806a2c9ff16ae9" }, { "path": "src/cli/commands/generated-intents.ts", - "sizeBytes": 108639, - "sha256": "0a3e20c1d14a9d9b66d1be3c6cc78343807f7588ec2f160fc46a6af65833d5b6" + "sizeBytes": 77835, + "sha256": "355aa098c818ed9b822f1b73e29fbc48cef8ffff180dfad38104b95cade90603" }, { "path": "dist/alphalib/types/robots/google-import.d.ts", @@ -2883,23 +2898,33 @@ }, { "path": "dist/cli/intentCommandSpecs.d.ts", - "sizeBytes": 247937, - "sha256": "c145a6b21cfa8d5b6e92ddbc8e7e8ce1b3c037019c42c778f57460cf3a028ed3" + "sizeBytes": 1499, + "sha256": "72015b58dfdcfcf52194487a0d72e68eab5917561f28bf8ae2ecb2a6c3319d3b" }, { "path": "src/cli/intentCommandSpecs.ts", - "sizeBytes": 8301, - "sha256": "12176f47a80112e90409bd858864e0e36592de6e52a60e5c1d8ab034569eee41" + "sizeBytes": 9207, + "sha256": "8eff37ffd84202c049ebda475ef22ce5175d474aa84f7b4d30936c0a0b911b14" }, { "path": "dist/cli/intentRuntime.d.ts", - "sizeBytes": 846, - "sha256": "9df958e592877cbf4ea4037110a8c3ed42c9a7ae98845c7ea039481d7d8b39b3" + "sizeBytes": 3679, + "sha256": "2941957647d34aad4d05c8e7ead1c56c53253d8794d3fa6b7a2b68be390cdaeb" }, { "path": "src/cli/intentRuntime.ts", - "sizeBytes": 4877, - "sha256": "8ec93528e4611fa86ba213e17a80c78615a50a01e2cd9440fb9e15aeb83b0445" + "sizeBytes": 13948, + "sha256": "ee546c1f51c1d896d3176eb5b37eac1a48a7992f8ab7a9ee6dab4a792c42abf6" + }, + { + "path": "dist/cli/intentSmokeCases.d.ts", + "sizeBytes": 337, + "sha256": "d3a0809ad489635cb567005d0e29b024acfc4b480474d81e379c0e98b1b2ba48" + }, + { + "path": "src/cli/intentSmokeCases.ts", + "sizeBytes": 2939, + "sha256": "b6939c7182cf90b73da1fa279c1d44aee97086deb5280ade2e47c57e197fef44" }, { "path": "src/alphalib/typings/json-to-ast.d.ts", From cfe3c44b613a0b38e838923ef62ac3b1d56356b4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 17:06:02 +0200 Subject: [PATCH 16/44] refactor(node): reduce intent and assembly duplication --- packages/node/src/cli/commands/assemblies.ts | 342 ++++++++++--------- packages/node/src/cli/intentRuntime.ts | 69 ++-- 2 files changed, 229 insertions(+), 182 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index c9c431c0..d0fdd3d3 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -487,8 +487,53 @@ function flattenAssemblyResults(results: Record { + await fsp.mkdir(baseDir, { recursive: true }) + + const targets: AssemblyDownloadTarget[] = [] + for (const resultFile of allFiles) { + const resultUrl = getResultFileUrl(resultFile.file) + if (resultUrl == null) { + continue + } + + const targetDir = groupByStep ? path.join(baseDir, resultFile.stepName) : baseDir + await fsp.mkdir(targetDir, { recursive: true }) + + targets.push({ + resultUrl, + targetPath: await ensureUniquePath(path.join(targetDir, getResultFileName(resultFile))), + }) + } + + return targets +} + +async function resolveResultDownloadTargets({ + allFiles, + entries, hasDirectoryInput, inPath, inputs, @@ -496,42 +541,21 @@ async function materializeAssemblyResults({ outputPath, outputRoot, outputRootIsDirectory, - outputctl, - results, singleAssembly, }: { - abortSignal: AbortSignal + allFiles: AssemblyResultFile[] + entries: Array<[string, Array]> hasDirectoryInput: boolean inPath: string | null inputs: string[] outputMode?: 'directory' | 'file' outputPath: string | null - outputRoot: string | null + outputRoot: string outputRootIsDirectory: boolean - outputctl: IOutputCtl - results: Record> singleAssembly?: boolean -}): Promise { - if (outputRoot == null) { - return - } - - const { allFiles, entries } = flattenAssemblyResults(results) +}): Promise { const shouldGroupByInput = !singleAssembly && inPath != null && (hasDirectoryInput || inputs.length > 1) - const useIntentDirectoryLayout = outputMode === 'directory' - - const downloadResultFile = async (resultUrl: string, targetPath: string): Promise => { - outputctl.debug('DOWNLOADING') - const [dlErr] = await tryCatch(downloadResultToFile(resultUrl, targetPath, abortSignal)) - if (dlErr) { - if (dlErr.name === 'AbortError') { - return - } - outputctl.error(dlErr.message) - throw dlErr - } - } const resolveDirectoryBaseDir = (): string => { if (!shouldGroupByInput || inPath == null) { @@ -550,89 +574,96 @@ async function materializeAssemblyResults({ if (!outputRootIsDirectory) { if (outputPath == null) { - return + return [] } const first = allFiles[0] const resultUrl = first == null ? null : getResultFileUrl(first.file) - if (resultUrl != null) { - await downloadResultFile(resultUrl, outputPath) - } - return + return resultUrl == null ? [] : [{ resultUrl, targetPath: outputPath }] } if (singleAssembly) { - await fsp.mkdir(outputRoot, { recursive: true }) - for (const { stepName, file } of allFiles) { - const resultUrl = getResultFileUrl(file) - if (resultUrl == null) { - continue - } - - const rawName = - file.name ?? - (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? - `${stepName}_result` - const safeName = sanitizeResultName(rawName) - const targetPath = await ensureUniquePath(path.join(outputRoot, safeName)) - await downloadResultFile(resultUrl, targetPath) - } - return + return await buildDirectoryDownloadTargets({ + allFiles, + baseDir: outputRoot, + groupByStep: false, + }) } - if (useIntentDirectoryLayout || outputPath == null) { - const baseDir = resolveDirectoryBaseDir() - await fsp.mkdir(baseDir, { recursive: true }) - const shouldUseStepDirectories = entries.length > 1 - - for (const { stepName, file } of allFiles) { - const resultUrl = getResultFileUrl(file) - if (resultUrl == null) { - continue - } - - const targetDir = shouldUseStepDirectories ? path.join(baseDir, stepName) : baseDir - await fsp.mkdir(targetDir, { recursive: true }) - - const rawName = - file.name ?? - (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? - `${stepName}_result` - const safeName = sanitizeResultName(rawName) - const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) - await downloadResultFile(resultUrl, targetPath) - } - - return + if (outputMode === 'directory' || outputPath == null) { + return await buildDirectoryDownloadTargets({ + allFiles, + baseDir: resolveDirectoryBaseDir(), + groupByStep: entries.length > 1, + }) } if (allFiles.length === 1) { const first = allFiles[0] const resultUrl = first == null ? null : getResultFileUrl(first.file) - if (resultUrl != null) { - await downloadResultFile(resultUrl, outputPath) - } - return + return resultUrl == null ? [] : [{ resultUrl, targetPath: outputPath }] } - const legacyBaseDir = path.join(path.dirname(outputPath), path.parse(outputPath).name) + return await buildDirectoryDownloadTargets({ + allFiles, + baseDir: path.join(path.dirname(outputPath), path.parse(outputPath).name), + groupByStep: true, + }) +} - for (const { stepName, file } of allFiles) { - const resultUrl = getResultFileUrl(file) - if (resultUrl == null) { - continue - } +async function materializeAssemblyResults({ + abortSignal, + hasDirectoryInput, + inPath, + inputs, + outputMode, + outputPath, + outputRoot, + outputRootIsDirectory, + outputctl, + results, + singleAssembly, +}: { + abortSignal: AbortSignal + hasDirectoryInput: boolean + inPath: string | null + inputs: string[] + outputMode?: 'directory' | 'file' + outputPath: string | null + outputRoot: string | null + outputRootIsDirectory: boolean + outputctl: IOutputCtl + results: Record> + singleAssembly?: boolean +}): Promise { + if (outputRoot == null) { + return + } - const targetDir = path.join(legacyBaseDir, stepName) - await fsp.mkdir(targetDir, { recursive: true }) + const { allFiles, entries } = flattenAssemblyResults(results) + const targets = await resolveResultDownloadTargets({ + allFiles, + entries, + hasDirectoryInput, + inPath, + inputs, + outputMode, + outputPath, + outputRoot, + outputRootIsDirectory, + singleAssembly, + }) - const rawName = - file.name ?? - (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? - `${stepName}_result` - const safeName = sanitizeResultName(rawName) - const targetPath = await ensureUniquePath(path.join(targetDir, safeName)) - await downloadResultFile(resultUrl, targetPath) + for (const { resultUrl, targetPath } of targets) { + outputctl.debug('DOWNLOADING') + const [dlErr] = await tryCatch(downloadResultToFile(resultUrl, targetPath, abortSignal)) + if (dlErr) { + if (dlErr.name === 'AbortError') { + continue + } + outputctl.error(dlErr.message) + throw dlErr + } } } @@ -1219,28 +1250,23 @@ export async function create( let hasFailures = false // AbortController to cancel all in-flight createAssembly calls when an error occurs const abortController = new AbortController() + const outputRootIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory()) - // Helper to process a single assembly job - async function processAssemblyJob( - inPath: string | null, - outputPlan: OutputPlan | null, - ): Promise { - outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) - - // Create fresh streams for this job - const inStream = inPath ? fs.createReadStream(inPath) : null - inStream?.on('error', () => {}) - + function createAssemblyOptions(uploads?: Record): CreateAssemblyOptions { const createOptions: CreateAssemblyOptions = { params, signal: abortController.signal, } - if (inStream != null) { - createOptions.uploads = { in: inStream } + if (uploads != null && Object.keys(uploads).length > 0) { + createOptions.uploads = uploads } + return createOptions + } + async function awaitCompletedAssembly( + createOptions: CreateAssemblyOptions, + ): Promise>> { const result = await client.createAssembly(createOptions) - const assemblyId = result.assembly_id if (!assemblyId) throw new Error('No assembly_id in result') @@ -1258,29 +1284,67 @@ export async function create( throw new Error(msg) } + return assembly + } + + async function executeAssemblyLifecycle({ + createOptions, + inPath, + inputPaths, + outputPlan, + singleAssemblyMode, + }: { + createOptions: CreateAssemblyOptions + inPath: string | null + inputPaths: string[] + outputPlan: OutputPlan | null + singleAssemblyMode?: boolean + }): Promise { + outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) + + const assembly = await awaitCompletedAssembly(createOptions) if (!assembly.results) throw new Error('No results in assembly') await materializeAssemblyResults({ abortSignal: abortController.signal, - hasDirectoryInput, + hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput, inPath, - inputs, + inputs: inputPaths, outputMode, outputPath: outputPlan?.path ?? null, outputRoot: resolvedOutput ?? null, - outputRootIsDirectory: Boolean(resolvedOutput != null && outstat?.isDirectory()), + outputRootIsDirectory, outputctl, results: assembly.results, + singleAssembly: singleAssemblyMode, }) outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) - if (del && inPath) { - await fsp.unlink(inPath) + if (del) { + for (const inputPath of inputPaths) { + await fsp.unlink(inputPath) + } } return assembly } + // Helper to process a single assembly job + async function processAssemblyJob( + inPath: string | null, + outputPlan: OutputPlan | null, + ): Promise { + const inStream = inPath ? fs.createReadStream(inPath) : null + inStream?.on('error', () => {}) + + return await executeAssemblyLifecycle({ + createOptions: createAssemblyOptions(inStream == null ? undefined : { in: inStream }), + inPath, + inputPaths: inPath == null ? [] : [inPath], + outputPlan, + }) + } + if (singleAssembly) { // Single-assembly mode: collect file paths, then create one assembly with all inputs // We close streams immediately to avoid exhausting file descriptors with many files @@ -1329,54 +1393,18 @@ export async function create( try { const assembly = await queue.add(async () => { - const createOptions: CreateAssemblyOptions = { - params, - signal: abortController.signal, - } - if (Object.keys(uploads).length > 0) { - createOptions.uploads = uploads - } - - const result = await client.createAssembly(createOptions) - const assemblyId = result.assembly_id - if (!assemblyId) throw new Error('No assembly_id in result') - - const asm = await client.awaitAssemblyCompletion(assemblyId, { - signal: abortController.signal, - onAssemblyProgress: (status) => { - outputctl.debug(`Assembly status: ${status.ok}`) - }, + return await executeAssemblyLifecycle({ + createOptions: createAssemblyOptions(uploads), + inPath: null, + inputPaths, + outputPlan: + resolvedOutput == null + ? null + : outputRootIsDirectory + ? { kind: 'file', mtime: new Date(0), path: resolvedOutput } + : { kind: 'file', mtime: new Date(0), path: resolvedOutput }, + singleAssemblyMode: true, }) - - if (asm.error || (asm.ok && asm.ok !== 'ASSEMBLY_COMPLETED')) { - const msg = `Assembly failed: ${asm.error || asm.message} (Status: ${asm.ok})` - outputctl.error(msg) - throw new Error(msg) - } - - if (asm.results) { - await materializeAssemblyResults({ - abortSignal: abortController.signal, - hasDirectoryInput: false, - inPath: null, - inputs: inputPaths, - outputMode, - outputPath: resolvedOutput ?? null, - outputRoot: resolvedOutput ?? null, - outputRootIsDirectory: Boolean(resolvedOutput != null && outstat?.isDirectory()), - outputctl, - results: asm.results, - singleAssembly: true, - }) - } - - // Delete input files if requested - if (del) { - for (const inPath of inputPaths) { - await fsp.unlink(inPath) - } - } - return asm }) results.push(assembly) } catch (err) { diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 2ac59aad..2e6ca16d 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -405,19 +405,45 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase return 1 } - protected async runWithPreparedInputs( + protected validateBeforePreparingInputs( + rawValues: Record, + ): number | undefined { + return this.validateInputPresence(rawValues) + } + + protected validatePreparedInputs(_preparedInputs: PreparedIntentInputs): number | undefined { + return undefined + } + + protected async executePreparedInputs( rawValues: Record, preparedInputs: PreparedIntentInputs, ): Promise { + return await executeFileIntentCommand({ + client: this.client, + createOptions: this.getCreateOptions(preparedInputs.inputs), + definition: this.intentDefinition, + output: this.output, + outputPath: this.outputPath, + rawValues, + }) + } + + protected override async run(): Promise { + const rawValues = this.getIntentRawValues() + const validationError = this.validateBeforePreparingInputs(rawValues) + if (validationError != null) { + return validationError + } + + const preparedInputs = await this.prepareInputs() try { - return await executeFileIntentCommand({ - client: this.client, - createOptions: this.getCreateOptions(preparedInputs.inputs), - definition: this.intentDefinition, - output: this.output, - outputPath: this.outputPath, - rawValues, - }) + const preparedInputError = this.validatePreparedInputs(preparedInputs) + if (preparedInputError != null) { + return preparedInputError + } + + return await this.executePreparedInputs(rawValues, preparedInputs) } finally { await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) } @@ -449,8 +475,9 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn } } - protected override async run(): Promise { - const rawValues = this.getIntentRawValues() + protected override validateBeforePreparingInputs( + rawValues: Record, + ): number | undefined { const validationError = this.validateInputPresence(rawValues) if (validationError != null) { return validationError @@ -460,14 +487,17 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn this.output.error('--single-assembly cannot be used with --watch') return 1 } + return undefined + } - const preparedInputs = await this.prepareInputs() + protected override validatePreparedInputs( + preparedInputs: PreparedIntentInputs, + ): number | undefined { if (this.watch && preparedInputs.hasTransientInputs) { this.output.error('--watch is only supported for filesystem inputs') return 1 } - - return await this.runWithPreparedInputs(rawValues, preparedInputs) + return undefined } } @@ -480,15 +510,4 @@ export abstract class GeneratedBundledFileIntentCommand extends GeneratedFileInt singleAssembly: true, } } - - protected override async run(): Promise { - const rawValues = this.getIntentRawValues() - const validationError = this.validateInputPresence(rawValues) - if (validationError != null) { - return validationError - } - - const preparedInputs = await this.prepareInputs() - return await this.runWithPreparedInputs(rawValues, preparedInputs) - } } From 6be414155724a0b9bf2cf19b31f978f355456bef Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 19:09:59 +0200 Subject: [PATCH 17/44] refactor(node): trim leftover intent runtime state --- .../node/scripts/generate-intent-commands.ts | 1 + packages/node/src/cli/commands/assemblies.ts | 68 +++---------------- packages/node/src/cli/intentRuntime.ts | 13 +++- 3 files changed, 24 insertions(+), 58 deletions(-) diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 2fcc9e7e..70d062de 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -16,6 +16,7 @@ import { ZodUnion, } from 'zod' +import type { RobotMetaInput } from '../src/alphalib/types/robots/_instructions-primitives.ts' import type { IntentDefinition, IntentInputMode, diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index d0fdd3d3..d038b9d0 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -300,7 +300,6 @@ const stdinWithPath = process.stdin as unknown as { path: string } stdinWithPath.path = '/dev/stdin' interface OutputPlan { - kind: 'file' | 'stdout' mtime: Date path?: string } @@ -312,23 +311,17 @@ interface Job { type OutputPlanProvider = (inpath: string | null, indir?: string) => Promise -interface OutputPlanRegistry { - [key: string]: OutputPlan | undefined -} - interface JobEmitterOptions { allowOutputCollisions?: boolean recursive?: boolean outputPlanProvider: OutputPlanProvider singleAssembly?: boolean - outputPlanRegistry: OutputPlanRegistry watch?: boolean reprocessStale?: boolean } interface ReaddirJobEmitterOptions { dir: string - outputPlanRegistry: OutputPlanRegistry recursive?: boolean outputPlanProvider: OutputPlanProvider topdir?: string @@ -336,13 +329,11 @@ interface ReaddirJobEmitterOptions { interface SingleJobEmitterOptions { file: string - outputPlanRegistry: OutputPlanRegistry outputPlanProvider: OutputPlanProvider } interface WatchJobEmitterOptions { file: string - outputPlanRegistry: OutputPlanRegistry recursive?: boolean outputPlanProvider: OutputPlanProvider } @@ -367,13 +358,11 @@ async function myStat( function createOutputPlan(pathname: string | undefined, mtime: Date): OutputPlan { if (pathname == null) { return { - kind: 'stdout', mtime, } } return { - kind: 'file', mtime, path: pathname, } @@ -686,19 +675,12 @@ class MyEventEmitter extends EventEmitter { } class ReaddirJobEmitter extends MyEventEmitter { - constructor({ - dir, - outputPlanRegistry, - recursive, - outputPlanProvider, - topdir = dir, - }: ReaddirJobEmitterOptions) { + constructor({ dir, recursive, outputPlanProvider, topdir = dir }: ReaddirJobEmitterOptions) { super() process.nextTick(() => { this.processDirectory({ dir, - outputPlanRegistry, recursive, outputPlanProvider, topdir, @@ -710,7 +692,6 @@ class ReaddirJobEmitter extends MyEventEmitter { private async processDirectory({ dir, - outputPlanRegistry, recursive, outputPlanProvider, topdir, @@ -721,9 +702,7 @@ class ReaddirJobEmitter extends MyEventEmitter { for (const filename of files) { const file = path.normalize(path.join(dir, filename)) - pendingOperations.push( - this.processFile({ file, outputPlanRegistry, recursive, outputPlanProvider, topdir }), - ) + pendingOperations.push(this.processFile({ file, recursive, outputPlanProvider, topdir })) } await Promise.all(pendingOperations) @@ -732,13 +711,11 @@ class ReaddirJobEmitter extends MyEventEmitter { private async processFile({ file, - outputPlanRegistry, recursive = false, outputPlanProvider, topdir, }: { file: string - outputPlanRegistry: OutputPlanRegistry recursive?: boolean outputPlanProvider: OutputPlanProvider topdir: string @@ -750,7 +727,6 @@ class ReaddirJobEmitter extends MyEventEmitter { await new Promise((resolve, reject) => { const subdirEmitter = new ReaddirJobEmitter({ dir: file, - outputPlanRegistry, recursive, outputPlanProvider, topdir, @@ -762,7 +738,6 @@ class ReaddirJobEmitter extends MyEventEmitter { } } else { const outputPlan = await outputPlanProvider(file, topdir) - outputPlanRegistry[file] = outputPlan ?? undefined const instream = fs.createReadStream(file) // Attach a no-op error handler to prevent unhandled errors if stream is destroyed // before being consumed (e.g., due to output collision detection) @@ -773,13 +748,11 @@ class ReaddirJobEmitter extends MyEventEmitter { } class SingleJobEmitter extends MyEventEmitter { - constructor({ file, outputPlanRegistry, outputPlanProvider }: SingleJobEmitterOptions) { + constructor({ file, outputPlanProvider }: SingleJobEmitterOptions) { super() const normalizedFile = path.normalize(file) outputPlanProvider(normalizedFile).then((outputPlan) => { - outputPlanRegistry[normalizedFile] = outputPlan ?? undefined - let instream: Readable | null if (normalizedFile === '-') { if (tty.isatty(process.stdin.fd)) { @@ -830,10 +803,10 @@ class NullJobEmitter extends MyEventEmitter { class WatchJobEmitter extends MyEventEmitter { private watcher: NodeWatcher | null = null - constructor({ file, outputPlanRegistry, recursive, outputPlanProvider }: WatchJobEmitterOptions) { + constructor({ file, recursive, outputPlanProvider }: WatchJobEmitterOptions) { super() - this.init({ file, outputPlanRegistry, recursive, outputPlanProvider }).catch((err) => { + this.init({ file, recursive, outputPlanProvider }).catch((err) => { this.emit('error', err) }) @@ -853,7 +826,6 @@ class WatchJobEmitter extends MyEventEmitter { private async init({ file, - outputPlanRegistry, recursive, outputPlanProvider, }: WatchJobEmitterOptions): Promise { @@ -870,25 +842,21 @@ class WatchJobEmitter extends MyEventEmitter { this.watcher.on('close', () => this.emit('end')) this.watcher.on('change', (_evt: string, filename: string) => { const normalizedFile = path.normalize(filename) - this.handleChange(normalizedFile, topdir, outputPlanRegistry, outputPlanProvider).catch( - (err) => { - this.emit('error', err) - }, - ) + this.handleChange(normalizedFile, topdir, outputPlanProvider).catch((err) => { + this.emit('error', err) + }) }) } private async handleChange( normalizedFile: string, topdir: string | undefined, - outputPlanRegistry: OutputPlanRegistry, outputPlanProvider: OutputPlanProvider, ): Promise { const stats = await fsp.stat(normalizedFile) if (stats.isDirectory()) return const outputPlan = await outputPlanProvider(normalizedFile, topdir) - outputPlanRegistry[normalizedFile] = outputPlan ?? undefined const instream = fs.createReadStream(normalizedFile) // Attach a no-op error handler to prevent unhandled errors if stream is destroyed @@ -1023,7 +991,6 @@ function makeJobEmitter( recursive, outputPlanProvider, singleAssembly, - outputPlanRegistry, watch: watchOption, reprocessStale, }: JobEmitterOptions, @@ -1036,9 +1003,7 @@ function makeJobEmitter( async function processInputs(): Promise { for (const input of inputs) { if (input === '-') { - emitterFns.push( - () => new SingleJobEmitter({ file: input, outputPlanProvider, outputPlanRegistry }), - ) + emitterFns.push(() => new SingleJobEmitter({ file: input, outputPlanProvider })) watcherFns.push(() => new NullJobEmitter()) } else { const stats = await fsp.stat(input) @@ -1049,7 +1014,6 @@ function makeJobEmitter( dir: input, recursive, outputPlanProvider, - outputPlanRegistry, }), ) watcherFns.push( @@ -1058,20 +1022,16 @@ function makeJobEmitter( file: input, recursive, outputPlanProvider, - outputPlanRegistry, }), ) } else { - emitterFns.push( - () => new SingleJobEmitter({ file: input, outputPlanProvider, outputPlanRegistry }), - ) + emitterFns.push(() => new SingleJobEmitter({ file: input, outputPlanProvider })) watcherFns.push( () => new WatchJobEmitter({ file: input, recursive, outputPlanProvider, - outputPlanRegistry, }), ) } @@ -1232,12 +1192,10 @@ export async function create( : outstat?.isDirectory() ? dirProvider(resolvedOutput) : fileProvider(resolvedOutput) - const outputPlanRegistry: OutputPlanRegistry = {} const emitter = makeJobEmitter(inputs, { allowOutputCollisions: singleAssembly, outputPlanProvider, - outputPlanRegistry, recursive, watch: watchOption, singleAssembly, @@ -1398,11 +1356,7 @@ export async function create( inPath: null, inputPaths, outputPlan: - resolvedOutput == null - ? null - : outputRootIsDirectory - ? { kind: 'file', mtime: new Date(0), path: resolvedOutput } - : { kind: 'file', mtime: new Date(0), path: resolvedOutput }, + resolvedOutput == null ? null : createOutputPlan(resolvedOutput, new Date(0)), singleAssemblyMode: true, }) }) diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 2e6ca16d..3b43139d 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -382,10 +382,14 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase } } + protected getProvidedInputCount(): number { + return (this.inputs ?? []).length + (this.inputBase64 ?? []).length + } + protected validateInputPresence( rawValues: Record, ): number | undefined { - const inputCount = (this.inputs ?? []).length + (this.inputBase64 ?? []).length + const inputCount = this.getProvidedInputCount() if (inputCount !== 0) { return undefined } @@ -483,6 +487,13 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn return validationError } + if (this.watch && this.getProvidedInputCount() === 0) { + this.output.error( + `${this.intentDefinition.commandLabel} --watch requires --input or --input-base64`, + ) + return 1 + } + if (this.singleAssembly && this.watch) { this.output.error('--single-assembly cannot be used with --watch') return 1 From 017fa2c36445b5b14b5cd833d6512ad465949045 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 20:03:28 +0200 Subject: [PATCH 18/44] refactor(node): centralize intent command resolution --- .../node/scripts/generate-intent-commands.ts | 604 +----------------- packages/node/src/cli/commands/assemblies.ts | 43 +- .../node/src/cli/intentResolvedDefinitions.ts | 578 +++++++++++++++++ packages/transloadit/package.json | 9 +- scripts/prepare-transloadit.ts | 23 + 5 files changed, 640 insertions(+), 617 deletions(-) create mode 100644 packages/node/src/cli/intentResolvedDefinitions.ts diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 70d062de..6552c601 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -1,580 +1,19 @@ import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { execa } from 'execa' -import type { ZodObject } from 'zod' -import { - ZodBoolean, - ZodDefault, - ZodEffects, - ZodEnum, - ZodLiteral, - ZodNullable, - ZodNumber, - ZodOptional, - ZodString, - ZodUnion, -} from 'zod' - -import type { RobotMetaInput } from '../src/alphalib/types/robots/_instructions-primitives.ts' -import type { - IntentDefinition, - IntentInputMode, - IntentOutputMode, - RobotIntentDefinition, -} from '../src/cli/intentCommandSpecs.ts' -import { - getIntentPaths, - getIntentResultStepName, - intentCatalog, -} from '../src/cli/intentCommandSpecs.ts' - -type GeneratedFieldKind = 'auto' | 'boolean' | 'number' | 'string' - -interface GeneratedSchemaField { - description?: string - kind: GeneratedFieldKind - name: string - optionFlags: string - propertyName: string - required: boolean -} - -interface ResolvedIntentLocalFilesInput { - allowConcurrency?: boolean - allowSingleAssembly?: boolean - allowWatch?: boolean - defaultSingleAssembly?: boolean - deleteAfterProcessing?: boolean - description: string - kind: 'local-files' - requiredFieldForInputless?: string - recursive?: boolean - reprocessStale?: boolean -} - -interface ResolvedIntentNoneInput { - kind: 'none' -} - -type ResolvedIntentInput = ResolvedIntentLocalFilesInput | ResolvedIntentNoneInput -interface ResolvedIntentSchemaSpec { - importName: string - importPath: string - schema: ZodObject> -} - -interface ResolvedIntentSingleStepExecution { - fixedValues: Record - kind: 'single-step' - resultStepName: string -} - -interface ResolvedIntentTemplateExecution { - kind: 'template' - templateId: string -} - -type ResolvedIntentExecution = ResolvedIntentSingleStepExecution | ResolvedIntentTemplateExecution - -interface ResolvedIntentCommandSpec { - className: string - description: string - details: string - examples: Array<[string, string]> - execution: ResolvedIntentExecution - input: ResolvedIntentInput - outputDescription: string - outputMode?: IntentOutputMode - outputRequired: boolean - paths: string[] - schemaSpec?: ResolvedIntentSchemaSpec -} +import { execa } from 'execa' -const hiddenFieldNames = new Set([ - 'ffmpeg_stack', - 'force_accept', - 'ignore_errors', - 'imagemagick_stack', - 'output_meta', - 'queue', - 'result', - 'robot', - 'stack', - 'use', -]) +import type { + GeneratedSchemaField, + ResolvedIntentCommandSpec, +} from '../src/cli/intentResolvedDefinitions.ts' +import { resolveIntentCommandSpecs } from '../src/cli/intentResolvedDefinitions.ts' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const packageRoot = path.resolve(__dirname, '..') const outputPath = path.resolve(__dirname, '../src/cli/commands/generated-intents.ts') -function toCamelCase(value: string): string { - return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()) -} - -function toKebabCase(value: string): string { - return value.replaceAll('_', '-') -} - -function toPascalCase(parts: string[]): string { - return parts - .flatMap((part) => part.split('-')) - .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) - .join('') -} - -function stripTrailingPunctuation(value: string): string { - return value.replace(/[.:]+$/, '').trim() -} - -function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { - let schema = input - let required = true - - while (true) { - if (schema instanceof ZodEffects) { - schema = schema._def.schema - continue - } - - if (schema instanceof ZodOptional) { - required = false - schema = schema.unwrap() - continue - } - - if (schema instanceof ZodDefault) { - required = false - schema = schema.removeDefault() - continue - } - - if (schema instanceof ZodNullable) { - required = false - schema = schema.unwrap() - continue - } - - return { required, schema } - } -} - -function getFieldKind(schema: unknown): GeneratedFieldKind { - if (schema instanceof ZodEffects) { - return getFieldKind(schema._def.schema) - } - - if (schema instanceof ZodString || schema instanceof ZodEnum) { - return 'string' - } - - if (schema instanceof ZodNumber) { - return 'number' - } - - if (schema instanceof ZodBoolean) { - return 'boolean' - } - - if (schema instanceof ZodLiteral) { - if (typeof schema.value === 'number') return 'number' - if (typeof schema.value === 'boolean') return 'boolean' - return 'string' - } - - if (schema instanceof ZodUnion) { - const optionKinds = new Set(schema._def.options.map((option) => getFieldKind(option))) - if (optionKinds.size === 1) { - const [kind] = optionKinds - if (kind != null) return kind - } - return 'auto' - } - - throw new Error('Unsupported schema type') -} - -function inferClassName(paths: string[]): string { - return `${toPascalCase(paths)}Command` -} - -function inferInputMode(definition: RobotIntentDefinition): IntentInputMode { - if (definition.inputMode != null) { - return definition.inputMode - } - - const shape = (definition.schema as ZodObject>).shape - if ('prompt' in shape) { - const promptSchema = shape.prompt - const { required } = unwrapSchema(promptSchema) - return required ? 'none' : 'local-files' - } - - return 'local-files' -} - -function inferOutputMode(definition: IntentDefinition): IntentOutputMode { - return definition.outputMode ?? 'file' -} - -function inferDescription(definition: RobotIntentDefinition): string { - return stripTrailingPunctuation(definition.meta.title) -} - -function inferOutputDescription(inputMode: IntentInputMode, outputMode: IntentOutputMode): string { - if (outputMode === 'directory') { - return 'Write the results to this directory' - } - - if (inputMode === 'local-files') { - return 'Write the result to this path or directory' - } - - return 'Write the result to this path' -} - -function inferDetails( - definition: RobotIntentDefinition, - inputMode: IntentInputMode, - outputMode: IntentOutputMode, - defaultSingleAssembly: boolean, -): string { - if (inputMode === 'none') { - return `Runs \`${definition.robot}\` and writes the result to \`--out\`.` - } - - if (defaultSingleAssembly) { - return `Runs \`${definition.robot}\` for the provided inputs and writes the result to \`--out\`.` - } - - if (outputMode === 'directory') { - return `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` - } - - return `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.` -} - -function inferLocalFilesInput({ - defaultSingleAssembly = false, - requiredFieldForInputless, -}: { - defaultSingleAssembly?: boolean - requiredFieldForInputless?: string -}): ResolvedIntentLocalFilesInput { - if (defaultSingleAssembly) { - return { - kind: 'local-files', - description: 'Provide one or more input paths, directories, URLs, or - for stdin', - recursive: true, - deleteAfterProcessing: true, - reprocessStale: true, - defaultSingleAssembly: true, - requiredFieldForInputless, - } - } - - return { - kind: 'local-files', - description: 'Provide an input path, directory, URL, or - for stdin', - recursive: true, - allowWatch: true, - deleteAfterProcessing: true, - reprocessStale: true, - allowSingleAssembly: true, - allowConcurrency: true, - requiredFieldForInputless, - } -} - -function inferInputSpec(definition: RobotIntentDefinition): ResolvedIntentInput { - const inputMode = inferInputMode(definition) - if (inputMode === 'none') { - return { kind: 'none' } - } - - const shape = (definition.schema as ZodObject>).shape - const requiredFieldForInputless = - 'prompt' in shape && !unwrapSchema(shape.prompt).required ? 'prompt' : undefined - - return inferLocalFilesInput({ - defaultSingleAssembly: definition.defaultSingleAssembly, - requiredFieldForInputless, - }) -} - -function inferFixedValues( - definition: RobotIntentDefinition, - inputMode: IntentInputMode, -): Record { - const shape = (definition.schema as ZodObject>).shape - const promptIsOptional = 'prompt' in shape && !unwrapSchema(shape.prompt).required - - if (definition.defaultSingleAssembly) { - return { - robot: definition.robot, - result: true, - use: { - steps: [':original'], - bundle_steps: true, - }, - } - } - - if (inputMode === 'local-files') { - if (promptIsOptional) { - return { - robot: definition.robot, - result: true, - } - } - - return { - robot: definition.robot, - result: true, - use: ':original', - } - } - - return { - robot: definition.robot, - result: true, - } -} - -function inferResultStepName(robot: string): string { - const definition = intentCatalog.find( - (intent): intent is RobotIntentDefinition => intent.kind === 'robot' && intent.robot === robot, - ) - if (definition == null) { - throw new Error(`No intent definition found for "${robot}"`) - } - - const stepName = getIntentResultStepName(definition) - if (stepName == null) { - throw new Error(`Could not infer result step name for "${robot}"`) - } - - return stepName -} - -function guessInputFile(meta: RobotMetaInput): string { - switch (meta.typical_file_type) { - case 'audio file': - return 'input.mp3' - case 'document': - return 'input.pdf' - case 'image': - return 'input.png' - case 'video': - return 'input.mp4' - default: - return 'input.file' - } -} - -function guessOutputPath( - definition: RobotIntentDefinition | null, - paths: string[], - outputMode: IntentOutputMode, -): string { - if (outputMode === 'directory') { - return 'output/' - } - - const [group] = paths - if (definition?.robot === '/file/compress') { - return 'archive.zip' - } - - if (group === 'audio') { - return 'output.png' - } - - if (group === 'document') { - return 'output.pdf' - } - - if (group === 'image') { - return 'output.png' - } - - if (group === 'text') { - return 'output.mp3' - } - - return 'output.file' -} - -function guessPromptExample(robot: string): string { - if (robot === '/image/generate') { - return 'A red bicycle in a studio' - } - - return 'Hello world' -} - -function inferExamples( - definition: RobotIntentDefinition | null, - paths: string[], - inputMode: IntentInputMode, - outputMode: IntentOutputMode, - fieldSpecs: GeneratedSchemaField[], -): Array<[string, string]> { - const parts = ['transloadit', ...paths] - - if (inputMode === 'local-files' && definition != null) { - parts.push('--input', guessInputFile(definition.meta)) - } - - if (inputMode === 'none' && definition != null) { - parts.push('--prompt', JSON.stringify(guessPromptExample(definition.robot))) - } - - if (definition != null) { - for (const fieldSpec of fieldSpecs) { - if (!fieldSpec.required) continue - if (fieldSpec.name === 'prompt' && inputMode === 'none') continue - - const exampleValue = inferExampleValue(definition, fieldSpec) - if (exampleValue == null) continue - parts.push(fieldSpec.optionFlags, exampleValue) - } - } - - parts.push('--out', guessOutputPath(definition, paths, outputMode)) - - return [['Run the command', parts.join(' ')]] -} - -function inferExampleValue( - definition: RobotIntentDefinition, - fieldSpec: GeneratedSchemaField, -): string | null { - if (fieldSpec.name === 'aspect_ratio') return '1:1' - if (fieldSpec.name === 'format') { - if (definition.robot === '/document/convert') return 'pdf' - if (definition.robot === '/file/compress') return 'zip' - if (definition.robot === '/video/thumbs') return 'jpg' - return 'png' - } - if (fieldSpec.name === 'model') return 'flux-schnell' - if (fieldSpec.name === 'prompt') return JSON.stringify(guessPromptExample(definition.robot)) - if (fieldSpec.name === 'provider') return 'aws' - if (fieldSpec.name === 'target_language') return 'en-US' - if (fieldSpec.name === 'voice') return 'female-1' - - if (fieldSpec.kind === 'boolean') return 'true' - if (fieldSpec.kind === 'number') return '1' - - return 'value' -} - -function collectSchemaFields( - schemaSpec: ResolvedIntentSchemaSpec, - fixedValues: Record, - input: ResolvedIntentInput, -): GeneratedSchemaField[] { - const shape = (schemaSpec.schema as ZodObject>).shape - - return Object.entries(shape) - .filter(([key]) => !hiddenFieldNames.has(key) && !Object.hasOwn(fixedValues, key)) - .flatMap(([key, fieldSchema]) => { - const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) - - let kind: GeneratedFieldKind - try { - kind = getFieldKind(unwrappedSchema) - } catch { - return [] - } - - const required = (input.kind === 'none' && key === 'prompt') || schemaRequired - - return [ - { - name: key, - propertyName: toCamelCase(key), - optionFlags: `--${toKebabCase(key)}`, - required, - description: fieldSchema.description, - kind, - }, - ] - }) -} - -function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { - const paths = getIntentPaths(definition) - const inputMode = inferInputMode(definition) - const outputMode = inferOutputMode(definition) - const input = inferInputSpec(definition) - const schemaSpec = { - importName: definition.schemaImportName, - importPath: definition.schemaImportPath, - schema: definition.schema as ZodObject>, - } satisfies ResolvedIntentSchemaSpec - const execution = { - kind: 'single-step', - resultStepName: inferResultStepName(definition.robot), - fixedValues: inferFixedValues(definition, inputMode), - } satisfies ResolvedIntentSingleStepExecution - const fieldSpecs = collectSchemaFields(schemaSpec, execution.fixedValues, input) - - return { - className: inferClassName(paths), - description: inferDescription(definition), - details: inferDetails( - definition, - inputMode, - outputMode, - definition.defaultSingleAssembly === true, - ), - examples: inferExamples(definition, paths, inputMode, outputMode, fieldSpecs), - input, - outputDescription: inferOutputDescription(inputMode, outputMode), - outputMode, - outputRequired: true, - paths, - schemaSpec, - execution, - } -} - -function resolveTemplateIntentSpec( - definition: IntentDefinition & { kind: 'template' }, -): ResolvedIntentCommandSpec { - const outputMode = inferOutputMode(definition) - const input = inferLocalFilesInput({}) - const paths = getIntentPaths(definition) - - return { - className: inferClassName(paths), - description: `Run ${stripTrailingPunctuation(definition.templateId)}`, - details: `Runs the \`${definition.templateId}\` template and writes the outputs to \`--out\`.`, - examples: [ - ['Run the command', `transloadit ${paths.join(' ')} --input input.mp4 --out output/`], - ], - execution: { - kind: 'template', - templateId: definition.templateId, - }, - input, - outputDescription: inferOutputDescription('local-files', outputMode), - outputMode, - outputRequired: true, - paths, - } -} - -function resolveIntentCommandSpec(definition: IntentDefinition): ResolvedIntentCommandSpec { - if (definition.kind === 'robot') { - return resolveRobotIntentSpec(definition) - } - - return resolveTemplateIntentSpec(definition) -} - function formatDescription(description: string | undefined): string { return JSON.stringify((description ?? '').trim()) } @@ -619,14 +58,6 @@ ${fieldSpecs ]` } -function resolveFixedValues(spec: ResolvedIntentCommandSpec): Record { - if (spec.execution.kind === 'single-step') { - return spec.execution.fixedValues - } - - return {} -} - function generateImports(specs: ResolvedIntentCommandSpec[]): string { const imports = new Map() @@ -658,19 +89,15 @@ function getBaseClassName(spec: ResolvedIntentCommandSpec): string { } function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { - const fieldSpecs = - spec.schemaSpec == null - ? [] - : collectSchemaFields(spec.schemaSpec, resolveFixedValues(spec), spec.input) - const commandLabel = spec.paths.join(' ') - if (spec.execution.kind === 'single-step') { const attachUseWhenInputsProvided = spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null ? '\n attachUseWhenInputsProvided: true,' : '' const commandLabelLine = - spec.input.kind === 'local-files' ? `\n commandLabel: ${JSON.stringify(commandLabel)},` : '' + spec.input.kind === 'local-files' + ? `\n commandLabel: ${JSON.stringify(spec.commandLabel)},` + : '' const requiredField = spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null ? `\n requiredFieldForInputless: ${JSON.stringify(spec.input.requiredFieldForInputless)},` @@ -682,7 +109,7 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { execution: { kind: 'single-step', schema: ${spec.schemaSpec?.importName}, - fieldSpecs: ${formatFieldSpecsLiteral(fieldSpecs)}, + fieldSpecs: ${formatFieldSpecsLiteral(spec.fieldSpecs)}, fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 4).replace(/\n/g, '\n ')}, resultStepName: ${JSON.stringify(spec.execution.resultStepName)},${attachUseWhenInputsProvided} }, @@ -692,7 +119,7 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` return `const ${getCommandDefinitionName(spec)} = { - commandLabel: ${JSON.stringify(commandLabel)},${outputMode} + commandLabel: ${JSON.stringify(spec.commandLabel)},${outputMode} execution: { kind: 'template', templateId: ${JSON.stringify(spec.execution.templateId)}, @@ -707,11 +134,8 @@ function formatRawValuesMethod(fieldSpecs: GeneratedSchemaField[]): string { } function generateClass(spec: ResolvedIntentCommandSpec): string { - const fixedValues = resolveFixedValues(spec) - const fieldSpecs = - spec.schemaSpec == null ? [] : collectSchemaFields(spec.schemaSpec, fixedValues, spec.input) - const schemaFields = formatSchemaFields(fieldSpecs) - const rawValuesMethod = formatRawValuesMethod(fieldSpecs) + const schemaFields = formatSchemaFields(spec.fieldSpecs) + const rawValuesMethod = formatRawValuesMethod(spec.fieldSpecs) const baseClassName = getBaseClassName(spec) return ` @@ -764,7 +188,7 @@ ${commandNames.map((name) => ` ${name},`).join('\n')} } async function main(): Promise { - const resolvedSpecs = intentCatalog.map(resolveIntentCommandSpec) + const resolvedSpecs = resolveIntentCommandSpecs() await mkdir(path.dirname(outputPath), { recursive: true }) await writeFile(outputPath, generateFile(resolvedSpecs)) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index d038b9d0..d61bf85a 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -355,6 +355,24 @@ async function myStat( return await fsp.stat(filepath) } +function createInputJobStream(filepath: string): Readable | null { + const normalizedFile = path.normalize(filepath) + + if (normalizedFile === '-') { + if (tty.isatty(process.stdin.fd)) { + return null + } + + return process.stdin + } + + const instream = fs.createReadStream(normalizedFile) + // Attach a no-op error handler to prevent unhandled errors if stream is destroyed + // before being consumed (e.g., due to output collision detection) + instream.on('error', () => {}) + return instream +} + function createOutputPlan(pathname: string | undefined, mtime: Date): OutputPlan { if (pathname == null) { return { @@ -738,10 +756,7 @@ class ReaddirJobEmitter extends MyEventEmitter { } } else { const outputPlan = await outputPlanProvider(file, topdir) - const instream = fs.createReadStream(file) - // Attach a no-op error handler to prevent unhandled errors if stream is destroyed - // before being consumed (e.g., due to output collision detection) - instream.on('error', () => {}) + const instream = createInputJobStream(file) this.emit('job', { in: instream, out: outputPlan }) } } @@ -753,19 +768,7 @@ class SingleJobEmitter extends MyEventEmitter { const normalizedFile = path.normalize(file) outputPlanProvider(normalizedFile).then((outputPlan) => { - let instream: Readable | null - if (normalizedFile === '-') { - if (tty.isatty(process.stdin.fd)) { - instream = null - } else { - instream = process.stdin - } - } else { - instream = fs.createReadStream(normalizedFile) - // Attach a no-op error handler to prevent unhandled errors if stream is destroyed - // before being consumed (e.g., due to output collision detection) - instream.on('error', () => {}) - } + const instream = createInputJobStream(normalizedFile) process.nextTick(() => { this.emit('job', { in: instream, out: outputPlan }) @@ -857,11 +860,7 @@ class WatchJobEmitter extends MyEventEmitter { if (stats.isDirectory()) return const outputPlan = await outputPlanProvider(normalizedFile, topdir) - - const instream = fs.createReadStream(normalizedFile) - // Attach a no-op error handler to prevent unhandled errors if stream is destroyed - // before being consumed (e.g., due to output collision detection) - instream.on('error', () => {}) + const instream = createInputJobStream(normalizedFile) this.emit('job', { in: instream, out: outputPlan }) } } diff --git a/packages/node/src/cli/intentResolvedDefinitions.ts b/packages/node/src/cli/intentResolvedDefinitions.ts new file mode 100644 index 00000000..7328016a --- /dev/null +++ b/packages/node/src/cli/intentResolvedDefinitions.ts @@ -0,0 +1,578 @@ +import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod' +import { + ZodBoolean, + ZodDefault, + ZodEffects, + ZodEnum, + ZodLiteral, + ZodNullable, + ZodNumber, + ZodOptional, + ZodString, + ZodUnion, +} from 'zod' + +import type { RobotMetaInput } from '../alphalib/types/robots/_instructions-primitives.ts' +import type { + IntentDefinition, + IntentInputMode, + IntentOutputMode, + RobotIntentDefinition, +} from './intentCommandSpecs.ts' +import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intentCommandSpecs.ts' + +export type GeneratedFieldKind = 'auto' | 'boolean' | 'number' | 'string' + +export interface GeneratedSchemaField { + description?: string + kind: GeneratedFieldKind + name: string + optionFlags: string + propertyName: string + required: boolean +} + +export interface ResolvedIntentLocalFilesInput { + allowConcurrency?: boolean + allowSingleAssembly?: boolean + allowWatch?: boolean + defaultSingleAssembly?: boolean + deleteAfterProcessing?: boolean + description: string + kind: 'local-files' + requiredFieldForInputless?: string + recursive?: boolean + reprocessStale?: boolean +} + +export interface ResolvedIntentNoneInput { + kind: 'none' +} + +export type ResolvedIntentInput = ResolvedIntentLocalFilesInput | ResolvedIntentNoneInput + +export interface ResolvedIntentSchemaSpec { + importName: string + importPath: string + schema: ZodObject +} + +export interface ResolvedIntentSingleStepExecution { + fixedValues: Record + kind: 'single-step' + resultStepName: string +} + +export interface ResolvedIntentTemplateExecution { + kind: 'template' + templateId: string +} + +export type ResolvedIntentExecution = + | ResolvedIntentSingleStepExecution + | ResolvedIntentTemplateExecution + +export interface ResolvedIntentCommandSpec { + className: string + commandLabel: string + description: string + details: string + examples: Array<[string, string]> + execution: ResolvedIntentExecution + fieldSpecs: GeneratedSchemaField[] + input: ResolvedIntentInput + outputDescription: string + outputMode?: IntentOutputMode + outputRequired: boolean + paths: string[] + schemaSpec?: ResolvedIntentSchemaSpec +} + +const hiddenFieldNames = new Set([ + 'ffmpeg_stack', + 'force_accept', + 'ignore_errors', + 'imagemagick_stack', + 'output_meta', + 'queue', + 'result', + 'robot', + 'stack', + 'use', +]) + +function toCamelCase(value: string): string { + return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()) +} + +function toKebabCase(value: string): string { + return value.replaceAll('_', '-') +} + +function toPascalCase(parts: string[]): string { + return parts + .flatMap((part) => part.split('-')) + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join('') +} + +function stripTrailingPunctuation(value: string): string { + return value.replace(/[.:]+$/, '').trim() +} + +function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { + let schema = input + let required = true + + while (true) { + if (schema instanceof ZodEffects) { + schema = schema._def.schema + continue + } + + if (schema instanceof ZodOptional) { + required = false + schema = schema.unwrap() + continue + } + + if (schema instanceof ZodDefault) { + required = false + schema = schema.removeDefault() + continue + } + + if (schema instanceof ZodNullable) { + required = false + schema = schema.unwrap() + continue + } + + return { required, schema } + } +} + +function getFieldKind(schema: unknown): GeneratedFieldKind { + if (schema instanceof ZodEffects) { + return getFieldKind(schema._def.schema) + } + + if (schema instanceof ZodString || schema instanceof ZodEnum) { + return 'string' + } + + if (schema instanceof ZodNumber) { + return 'number' + } + + if (schema instanceof ZodBoolean) { + return 'boolean' + } + + if (schema instanceof ZodLiteral) { + if (typeof schema.value === 'number') return 'number' + if (typeof schema.value === 'boolean') return 'boolean' + return 'string' + } + + if (schema instanceof ZodUnion) { + const optionKinds = Array.from( + new Set(schema._def.options.map((option: unknown) => getFieldKind(option))), + ) as GeneratedFieldKind[] + if (optionKinds.length === 1) { + const [kind] = optionKinds + if (kind != null) return kind + } + return 'auto' + } + + throw new Error('Unsupported schema type') +} + +function inferClassName(paths: string[]): string { + return `${toPascalCase(paths)}Command` +} + +function inferInputMode(definition: RobotIntentDefinition): IntentInputMode { + if (definition.inputMode != null) { + return definition.inputMode + } + + const shape = (definition.schema as ZodObject).shape + if ('prompt' in shape) { + const promptSchema = shape.prompt + const { required } = unwrapSchema(promptSchema) + return required ? 'none' : 'local-files' + } + + return 'local-files' +} + +function inferOutputMode(definition: IntentDefinition): IntentOutputMode { + return definition.outputMode ?? 'file' +} + +function inferDescription(definition: RobotIntentDefinition): string { + return stripTrailingPunctuation(definition.meta.title) +} + +function inferOutputDescription(inputMode: IntentInputMode, outputMode: IntentOutputMode): string { + if (outputMode === 'directory') { + return 'Write the results to this directory' + } + + if (inputMode === 'local-files') { + return 'Write the result to this path or directory' + } + + return 'Write the result to this path' +} + +function inferDetails( + definition: RobotIntentDefinition, + inputMode: IntentInputMode, + outputMode: IntentOutputMode, + defaultSingleAssembly: boolean, +): string { + if (inputMode === 'none') { + return `Runs \`${definition.robot}\` and writes the result to \`--out\`.` + } + + if (defaultSingleAssembly) { + return `Runs \`${definition.robot}\` for the provided inputs and writes the result to \`--out\`.` + } + + if (outputMode === 'directory') { + return `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` + } + + return `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.` +} + +function inferLocalFilesInput({ + defaultSingleAssembly = false, + requiredFieldForInputless, +}: { + defaultSingleAssembly?: boolean + requiredFieldForInputless?: string +}): ResolvedIntentLocalFilesInput { + if (defaultSingleAssembly) { + return { + kind: 'local-files', + description: 'Provide one or more input paths, directories, URLs, or - for stdin', + recursive: true, + deleteAfterProcessing: true, + reprocessStale: true, + defaultSingleAssembly: true, + requiredFieldForInputless, + } + } + + return { + kind: 'local-files', + description: 'Provide an input path, directory, URL, or - for stdin', + recursive: true, + allowWatch: true, + deleteAfterProcessing: true, + reprocessStale: true, + allowSingleAssembly: true, + allowConcurrency: true, + requiredFieldForInputless, + } +} + +function inferInputSpec(definition: RobotIntentDefinition): ResolvedIntentInput { + const inputMode = inferInputMode(definition) + if (inputMode === 'none') { + return { kind: 'none' } + } + + const shape = (definition.schema as ZodObject).shape + const requiredFieldForInputless = + 'prompt' in shape && !unwrapSchema(shape.prompt).required ? 'prompt' : undefined + + return inferLocalFilesInput({ + defaultSingleAssembly: definition.defaultSingleAssembly, + requiredFieldForInputless, + }) +} + +function inferFixedValues( + definition: RobotIntentDefinition, + inputMode: IntentInputMode, +): Record { + const shape = (definition.schema as ZodObject).shape + const promptIsOptional = 'prompt' in shape && !unwrapSchema(shape.prompt).required + + if (definition.defaultSingleAssembly) { + return { + robot: definition.robot, + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + } + } + + if (inputMode === 'local-files') { + if (promptIsOptional) { + return { + robot: definition.robot, + result: true, + } + } + + return { + robot: definition.robot, + result: true, + use: ':original', + } + } + + return { + robot: definition.robot, + result: true, + } +} + +function inferResultStepName(robot: string): string { + const definition = intentCatalog.find( + (intent): intent is RobotIntentDefinition => intent.kind === 'robot' && intent.robot === robot, + ) + if (definition == null) { + throw new Error(`No intent definition found for "${robot}"`) + } + + const stepName = getIntentResultStepName(definition) + if (stepName == null) { + throw new Error(`Could not infer result step name for "${robot}"`) + } + + return stepName +} + +function guessInputFile(meta: RobotMetaInput): string { + switch (meta.typical_file_type) { + case 'audio file': + return 'input.mp3' + case 'document': + return 'input.pdf' + case 'image': + return 'input.png' + case 'video': + return 'input.mp4' + default: + return 'input.file' + } +} + +function guessOutputPath( + definition: RobotIntentDefinition | null, + paths: string[], + outputMode: IntentOutputMode, +): string { + if (outputMode === 'directory') { + return 'output/' + } + + const [group] = paths + if (definition?.robot === '/file/compress') { + return 'archive.zip' + } + + if (group === 'audio') { + return 'output.png' + } + + if (group === 'document') { + return 'output.pdf' + } + + if (group === 'image') { + return 'output.png' + } + + if (group === 'text') { + return 'output.mp3' + } + + return 'output.file' +} + +function guessPromptExample(robot: string): string { + if (robot === '/image/generate') { + return 'A red bicycle in a studio' + } + + return 'Hello world' +} + +function inferExampleValue( + definition: RobotIntentDefinition, + fieldSpec: GeneratedSchemaField, +): string | null { + if (fieldSpec.name === 'aspect_ratio') return '1:1' + if (fieldSpec.name === 'format') { + if (definition.robot === '/document/convert') return 'pdf' + if (definition.robot === '/file/compress') return 'zip' + if (definition.robot === '/video/thumbs') return 'jpg' + return 'png' + } + if (fieldSpec.name === 'model') return 'flux-schnell' + if (fieldSpec.name === 'prompt') return JSON.stringify(guessPromptExample(definition.robot)) + if (fieldSpec.name === 'provider') return 'aws' + if (fieldSpec.name === 'target_language') return 'en-US' + if (fieldSpec.name === 'voice') return 'female-1' + + if (fieldSpec.kind === 'boolean') return 'true' + if (fieldSpec.kind === 'number') return '1' + + return 'value' +} + +function inferExamples( + definition: RobotIntentDefinition | null, + paths: string[], + inputMode: IntentInputMode, + outputMode: IntentOutputMode, + fieldSpecs: GeneratedSchemaField[], +): Array<[string, string]> { + const parts = ['transloadit', ...paths] + + if (inputMode === 'local-files' && definition != null) { + parts.push('--input', guessInputFile(definition.meta)) + } + + if (inputMode === 'none' && definition != null) { + parts.push('--prompt', JSON.stringify(guessPromptExample(definition.robot))) + } + + if (definition != null) { + for (const fieldSpec of fieldSpecs) { + if (!fieldSpec.required) continue + if (fieldSpec.name === 'prompt' && inputMode === 'none') continue + + const exampleValue = inferExampleValue(definition, fieldSpec) + if (exampleValue == null) continue + parts.push(fieldSpec.optionFlags, exampleValue) + } + } + + parts.push('--out', guessOutputPath(definition, paths, outputMode)) + + return [['Run the command', parts.join(' ')]] +} + +function collectSchemaFields( + schemaSpec: ResolvedIntentSchemaSpec, + fixedValues: Record, + input: ResolvedIntentInput, +): GeneratedSchemaField[] { + const shape = (schemaSpec.schema as ZodObject).shape as Record + + return Object.entries(shape) + .filter(([key]) => !hiddenFieldNames.has(key) && !Object.hasOwn(fixedValues, key)) + .flatMap(([key, fieldSchema]) => { + const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) + + let kind: GeneratedFieldKind + try { + kind = getFieldKind(unwrappedSchema) + } catch { + return [] + } + + const required = (input.kind === 'none' && key === 'prompt') || schemaRequired + + return [ + { + name: key, + propertyName: toCamelCase(key), + optionFlags: `--${toKebabCase(key)}`, + required, + description: fieldSchema.description, + kind, + }, + ] + }) +} + +function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { + const paths = getIntentPaths(definition) + const inputMode = inferInputMode(definition) + const outputMode = inferOutputMode(definition) + const input = inferInputSpec(definition) + const schemaSpec = { + importName: definition.schemaImportName, + importPath: definition.schemaImportPath, + schema: definition.schema as ZodObject, + } satisfies ResolvedIntentSchemaSpec + const execution = { + kind: 'single-step', + resultStepName: inferResultStepName(definition.robot), + fixedValues: inferFixedValues(definition, inputMode), + } satisfies ResolvedIntentSingleStepExecution + const fieldSpecs = collectSchemaFields(schemaSpec, execution.fixedValues, input) + + return { + className: inferClassName(paths), + commandLabel: paths.join(' '), + description: inferDescription(definition), + details: inferDetails( + definition, + inputMode, + outputMode, + definition.defaultSingleAssembly === true, + ), + examples: inferExamples(definition, paths, inputMode, outputMode, fieldSpecs), + execution, + fieldSpecs, + input, + outputDescription: inferOutputDescription(inputMode, outputMode), + outputMode, + outputRequired: true, + paths, + schemaSpec, + } +} + +function resolveTemplateIntentSpec( + definition: IntentDefinition & { kind: 'template' }, +): ResolvedIntentCommandSpec { + const outputMode = inferOutputMode(definition) + const input = inferLocalFilesInput({}) + const paths = getIntentPaths(definition) + + return { + className: inferClassName(paths), + commandLabel: paths.join(' '), + description: `Run ${stripTrailingPunctuation(definition.templateId)}`, + details: `Runs the \`${definition.templateId}\` template and writes the outputs to \`--out\`.`, + examples: [ + ['Run the command', `transloadit ${paths.join(' ')} --input input.mp4 --out output/`], + ], + execution: { + kind: 'template', + templateId: definition.templateId, + }, + fieldSpecs: [], + input, + outputDescription: inferOutputDescription('local-files', outputMode), + outputMode, + outputRequired: true, + paths, + } +} + +export function resolveIntentCommandSpec(definition: IntentDefinition): ResolvedIntentCommandSpec { + if (definition.kind === 'robot') { + return resolveRobotIntentSpec(definition) + } + + return resolveTemplateIntentSpec(definition) +} + +export function resolveIntentCommandSpecs(): ResolvedIntentCommandSpec[] { + return intentCatalog.map(resolveIntentCommandSpec) +} diff --git a/packages/transloadit/package.json b/packages/transloadit/package.json index 0f1dab7b..99acf0ed 100644 --- a/packages/transloadit/package.json +++ b/packages/transloadit/package.json @@ -70,8 +70,7 @@ "src": "./src" }, "scripts": { - "sync:intents": "node scripts/generate-intent-commands.ts", - "check": "yarn sync:intents && yarn lint:ts && yarn fix && yarn test:unit", + "check": "yarn lint:ts && yarn test:unit", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", @@ -81,9 +80,9 @@ "lint:deps": "knip --dependencies --no-progress", "fix:deps": "knip --dependencies --no-progress --fix", "prepack": "node ../../scripts/prepare-transloadit.ts", - "test:unit": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage ./test/unit", - "test:e2e": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run ./test/e2e", - "test": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage" + "test:unit": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage --passWithNoTests ./test/unit", + "test:e2e": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --passWithNoTests ./test/e2e", + "test": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage --passWithNoTests" }, "license": "MIT", "main": "./dist/Transloadit.js", diff --git a/scripts/prepare-transloadit.ts b/scripts/prepare-transloadit.ts index d0f298c1..09942ceb 100644 --- a/scripts/prepare-transloadit.ts +++ b/scripts/prepare-transloadit.ts @@ -34,6 +34,29 @@ const writeLegacyPackageJson = async (): Promise => { () => null, ) const scripts = { ...(nodePackageJson.scripts ?? {}) } + delete scripts['sync:intents'] + if (scripts.check != null) { + scripts.check = scripts.check.replace('yarn sync:intents && ', '') + scripts.check = scripts.check.replace(' && yarn fix', '') + } + if (scripts['test:unit'] != null) { + scripts['test:unit'] = scripts['test:unit'].replace( + 'vitest run --coverage ./test/unit', + 'vitest run --coverage --passWithNoTests ./test/unit', + ) + } + if (scripts['test:e2e'] != null) { + scripts['test:e2e'] = scripts['test:e2e'].replace( + 'vitest run ./test/e2e', + 'vitest run --passWithNoTests ./test/e2e', + ) + } + if (scripts.test != null) { + scripts.test = scripts.test.replace( + 'vitest run --coverage', + 'vitest run --coverage --passWithNoTests', + ) + } scripts.prepack = 'node ../../scripts/prepare-transloadit.ts' const legacyPackageJson: PackageJson = { ...nodePackageJson, From 90b6be6f4b1f247690ad534dde3b1d0476768695 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 20:57:40 +0200 Subject: [PATCH 19/44] refactor(node): share intent field and analysis logic --- packages/node/src/cli/intentFields.ts | 94 ++++ .../node/src/cli/intentResolvedDefinitions.ts | 518 ++++++++---------- packages/node/src/cli/intentRuntime.ts | 57 +- 3 files changed, 319 insertions(+), 350 deletions(-) create mode 100644 packages/node/src/cli/intentFields.ts diff --git a/packages/node/src/cli/intentFields.ts b/packages/node/src/cli/intentFields.ts new file mode 100644 index 00000000..4b9af272 --- /dev/null +++ b/packages/node/src/cli/intentFields.ts @@ -0,0 +1,94 @@ +import type { z } from 'zod' +import { ZodBoolean, ZodEffects, ZodEnum, ZodLiteral, ZodNumber, ZodString, ZodUnion } from 'zod' + +export type IntentFieldKind = 'auto' | 'boolean' | 'number' | 'string' + +export interface IntentFieldSpec { + kind: IntentFieldKind + name: string +} + +export function inferIntentFieldKind(schema: unknown): IntentFieldKind { + if (schema instanceof ZodEffects) { + return inferIntentFieldKind(schema._def.schema) + } + + if (schema instanceof ZodString || schema instanceof ZodEnum) { + return 'string' + } + + if (schema instanceof ZodNumber) { + return 'number' + } + + if (schema instanceof ZodBoolean) { + return 'boolean' + } + + if (schema instanceof ZodLiteral) { + if (typeof schema.value === 'number') return 'number' + if (typeof schema.value === 'boolean') return 'boolean' + return 'string' + } + + if (schema instanceof ZodUnion) { + const optionKinds = Array.from( + new Set(schema._def.options.map((option: unknown) => inferIntentFieldKind(option))), + ) as IntentFieldKind[] + if (optionKinds.length === 1) { + const [kind] = optionKinds + if (kind != null) return kind + } + return 'auto' + } + + throw new Error('Unsupported schema type') +} + +export function coerceIntentFieldValue( + kind: IntentFieldKind, + raw: string, + fieldSchema?: z.ZodTypeAny, +): boolean | number | string { + if (kind === 'auto') { + if (fieldSchema == null) { + return raw + } + + const candidates: unknown[] = [raw] + + if (raw === 'true' || raw === 'false') { + candidates.push(raw === 'true') + } + + const numericValue = Number(raw) + if (raw.trim() !== '' && !Number.isNaN(numericValue)) { + candidates.push(numericValue) + } + + for (const candidate of candidates) { + const parsed = fieldSchema.safeParse(candidate) + if (parsed.success) { + return parsed.data as boolean | number | string + } + } + + return raw + } + + if (kind === 'number') { + const value = Number(raw) + if (Number.isNaN(value)) { + throw new Error(`Expected a number but received "${raw}"`) + } + return value + } + + if (kind === 'boolean') { + if (raw === 'true') return true + if (raw === 'false') return false + throw new Error(`Expected "true" or "false" but received "${raw}"`) + } + + return raw +} diff --git a/packages/node/src/cli/intentResolvedDefinitions.ts b/packages/node/src/cli/intentResolvedDefinitions.ts index 7328016a..9a094df9 100644 --- a/packages/node/src/cli/intentResolvedDefinitions.ts +++ b/packages/node/src/cli/intentResolvedDefinitions.ts @@ -1,16 +1,5 @@ import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod' -import { - ZodBoolean, - ZodDefault, - ZodEffects, - ZodEnum, - ZodLiteral, - ZodNullable, - ZodNumber, - ZodOptional, - ZodString, - ZodUnion, -} from 'zod' +import { ZodDefault, ZodEffects, ZodNullable, ZodOptional } from 'zod' import type { RobotMetaInput } from '../alphalib/types/robots/_instructions-primitives.ts' import type { @@ -20,13 +9,11 @@ import type { RobotIntentDefinition, } from './intentCommandSpecs.ts' import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intentCommandSpecs.ts' +import type { IntentFieldKind, IntentFieldSpec } from './intentFields.ts' +import { inferIntentFieldKind } from './intentFields.ts' -export type GeneratedFieldKind = 'auto' | 'boolean' | 'number' | 'string' - -export interface GeneratedSchemaField { +export interface GeneratedSchemaField extends IntentFieldSpec { description?: string - kind: GeneratedFieldKind - name: string optionFlags: string propertyName: string required: boolean @@ -88,6 +75,30 @@ export interface ResolvedIntentCommandSpec { schemaSpec?: ResolvedIntentSchemaSpec } +interface RobotIntentPresentation { + outputPath?: string + promptExample?: string + requiredExampleValues?: Partial> +} + +interface RobotIntentAnalysis { + className: string + commandLabel: string + definition: RobotIntentDefinition + details: string + description: string + execution: ResolvedIntentSingleStepExecution + fieldSpecs: GeneratedSchemaField[] + input: ResolvedIntentInput + inputMode: IntentInputMode + outputDescription: string + outputMode: IntentOutputMode + paths: string[] + presentation: RobotIntentPresentation + schemaShape: Record + schemaSpec: ResolvedIntentSchemaSpec +} + const hiddenFieldNames = new Set([ 'ffmpeg_stack', 'force_accept', @@ -101,6 +112,24 @@ const hiddenFieldNames = new Set([ 'use', ]) +const robotIntentPresentationOverrides: Partial> = { + '/document/convert': { + outputPath: 'output.pdf', + requiredExampleValues: { format: 'pdf' }, + }, + '/file/compress': { + outputPath: 'archive.zip', + requiredExampleValues: { format: 'zip' }, + }, + '/image/generate': { + promptExample: 'A red bicycle in a studio', + requiredExampleValues: { model: 'flux-schnell' }, + }, + '/video/thumbs': { + requiredExampleValues: { format: 'jpg' }, + }, +} + function toCamelCase(value: string): string { return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()) } @@ -152,110 +181,81 @@ function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { } } -function getFieldKind(schema: unknown): GeneratedFieldKind { - if (schema instanceof ZodEffects) { - return getFieldKind(schema._def.schema) - } - - if (schema instanceof ZodString || schema instanceof ZodEnum) { - return 'string' - } - - if (schema instanceof ZodNumber) { - return 'number' - } - - if (schema instanceof ZodBoolean) { - return 'boolean' - } - - if (schema instanceof ZodLiteral) { - if (typeof schema.value === 'number') return 'number' - if (typeof schema.value === 'boolean') return 'boolean' - return 'string' - } - - if (schema instanceof ZodUnion) { - const optionKinds = Array.from( - new Set(schema._def.options.map((option: unknown) => getFieldKind(option))), - ) as GeneratedFieldKind[] - if (optionKinds.length === 1) { - const [kind] = optionKinds - if (kind != null) return kind - } - return 'auto' +function getTypicalInputFile(meta: RobotMetaInput): string { + switch (meta.typical_file_type) { + case 'audio file': + return 'input.mp3' + case 'document': + return 'input.pdf' + case 'image': + return 'input.png' + case 'video': + return 'input.mp4' + default: + return 'input.file' } - - throw new Error('Unsupported schema type') } -function inferClassName(paths: string[]): string { - return `${toPascalCase(paths)}Command` -} - -function inferInputMode(definition: RobotIntentDefinition): IntentInputMode { - if (definition.inputMode != null) { - return definition.inputMode - } - - const shape = (definition.schema as ZodObject).shape - if ('prompt' in shape) { - const promptSchema = shape.prompt - const { required } = unwrapSchema(promptSchema) - return required ? 'none' : 'local-files' +function getDefaultOutputPath(paths: string[], outputMode: IntentOutputMode): string { + if (outputMode === 'directory') { + return 'output/' } - return 'local-files' -} - -function inferOutputMode(definition: IntentDefinition): IntentOutputMode { - return definition.outputMode ?? 'file' + const [group] = paths + if (group === 'audio') return 'output.png' + if (group === 'document') return 'output.pdf' + if (group === 'image') return 'output.png' + if (group === 'text') return 'output.mp3' + return 'output.file' } -function inferDescription(definition: RobotIntentDefinition): string { - return stripTrailingPunctuation(definition.meta.title) +function getDefaultPromptExample(robot: string): string { + return robotIntentPresentationOverrides[robot]?.promptExample ?? 'Hello world' } -function inferOutputDescription(inputMode: IntentInputMode, outputMode: IntentOutputMode): string { - if (outputMode === 'directory') { - return 'Write the results to this directory' - } - - if (inputMode === 'local-files') { - return 'Write the result to this path or directory' +function getDefaultRequiredExampleValue( + definition: RobotIntentDefinition, + fieldSpec: GeneratedSchemaField, +): string | null { + const override = + robotIntentPresentationOverrides[definition.robot]?.requiredExampleValues?.[fieldSpec.name] + if (override != null) { + return override } - return 'Write the result to this path' -} + if (fieldSpec.name === 'aspect_ratio') return '1:1' + if (fieldSpec.name === 'prompt') return JSON.stringify(getDefaultPromptExample(definition.robot)) + if (fieldSpec.name === 'provider') return 'aws' + if (fieldSpec.name === 'target_language') return 'en-US' + if (fieldSpec.name === 'voice') return 'female-1' -function inferDetails( - definition: RobotIntentDefinition, - inputMode: IntentInputMode, - outputMode: IntentOutputMode, - defaultSingleAssembly: boolean, -): string { - if (inputMode === 'none') { - return `Runs \`${definition.robot}\` and writes the result to \`--out\`.` - } + if (fieldSpec.kind === 'boolean') return 'true' + if (fieldSpec.kind === 'number') return '1' - if (defaultSingleAssembly) { - return `Runs \`${definition.robot}\` for the provided inputs and writes the result to \`--out\`.` - } + return 'value' +} - if (outputMode === 'directory') { - return `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` +function inferInputModeFromShape(shape: Record): IntentInputMode { + if ('prompt' in shape) { + return unwrapSchema(shape.prompt).required ? 'none' : 'local-files' } - return `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.` + return 'local-files' } -function inferLocalFilesInput({ - defaultSingleAssembly = false, +function inferInputSpecFromAnalysis({ + defaultSingleAssembly, + inputMode, requiredFieldForInputless, }: { defaultSingleAssembly?: boolean + inputMode: IntentInputMode requiredFieldForInputless?: string -}): ResolvedIntentLocalFilesInput { +}): ResolvedIntentInput { + if (inputMode === 'none') { + return { kind: 'none' } + } + if (defaultSingleAssembly) { return { kind: 'local-files', @@ -281,32 +281,20 @@ function inferLocalFilesInput({ } } -function inferInputSpec(definition: RobotIntentDefinition): ResolvedIntentInput { - const inputMode = inferInputMode(definition) - if (inputMode === 'none') { - return { kind: 'none' } - } - - const shape = (definition.schema as ZodObject).shape - const requiredFieldForInputless = - 'prompt' in shape && !unwrapSchema(shape.prompt).required ? 'prompt' : undefined - - return inferLocalFilesInput({ - defaultSingleAssembly: definition.defaultSingleAssembly, - requiredFieldForInputless, - }) -} - -function inferFixedValues( - definition: RobotIntentDefinition, - inputMode: IntentInputMode, -): Record { - const shape = (definition.schema as ZodObject).shape - const promptIsOptional = 'prompt' in shape && !unwrapSchema(shape.prompt).required - - if (definition.defaultSingleAssembly) { +function inferFixedValuesFromAnalysis({ + defaultSingleAssembly, + inputMode, + promptIsOptional, + robot, +}: { + defaultSingleAssembly?: boolean + inputMode: IntentInputMode + promptIsOptional: boolean + robot: string +}): Record { + if (defaultSingleAssembly) { return { - robot: definition.robot, + robot, result: true, use: { steps: [':original'], @@ -315,182 +303,43 @@ function inferFixedValues( } } - if (inputMode === 'local-files') { - if (promptIsOptional) { - return { - robot: definition.robot, - result: true, - } - } - + if (inputMode === 'local-files' && !promptIsOptional) { return { - robot: definition.robot, + robot, result: true, use: ':original', } } return { - robot: definition.robot, + robot, result: true, } } -function inferResultStepName(robot: string): string { - const definition = intentCatalog.find( - (intent): intent is RobotIntentDefinition => intent.kind === 'robot' && intent.robot === robot, - ) - if (definition == null) { - throw new Error(`No intent definition found for "${robot}"`) - } - - const stepName = getIntentResultStepName(definition) - if (stepName == null) { - throw new Error(`Could not infer result step name for "${robot}"`) - } - - return stepName -} - -function guessInputFile(meta: RobotMetaInput): string { - switch (meta.typical_file_type) { - case 'audio file': - return 'input.mp3' - case 'document': - return 'input.pdf' - case 'image': - return 'input.png' - case 'video': - return 'input.mp4' - default: - return 'input.file' - } -} - -function guessOutputPath( - definition: RobotIntentDefinition | null, - paths: string[], - outputMode: IntentOutputMode, -): string { - if (outputMode === 'directory') { - return 'output/' - } - - const [group] = paths - if (definition?.robot === '/file/compress') { - return 'archive.zip' - } - - if (group === 'audio') { - return 'output.png' - } - - if (group === 'document') { - return 'output.pdf' - } - - if (group === 'image') { - return 'output.png' - } - - if (group === 'text') { - return 'output.mp3' - } - - return 'output.file' -} - -function guessPromptExample(robot: string): string { - if (robot === '/image/generate') { - return 'A red bicycle in a studio' - } - - return 'Hello world' -} - -function inferExampleValue( - definition: RobotIntentDefinition, - fieldSpec: GeneratedSchemaField, -): string | null { - if (fieldSpec.name === 'aspect_ratio') return '1:1' - if (fieldSpec.name === 'format') { - if (definition.robot === '/document/convert') return 'pdf' - if (definition.robot === '/file/compress') return 'zip' - if (definition.robot === '/video/thumbs') return 'jpg' - return 'png' - } - if (fieldSpec.name === 'model') return 'flux-schnell' - if (fieldSpec.name === 'prompt') return JSON.stringify(guessPromptExample(definition.robot)) - if (fieldSpec.name === 'provider') return 'aws' - if (fieldSpec.name === 'target_language') return 'en-US' - if (fieldSpec.name === 'voice') return 'female-1' - - if (fieldSpec.kind === 'boolean') return 'true' - if (fieldSpec.kind === 'number') return '1' - - return 'value' -} - -function inferExamples( - definition: RobotIntentDefinition | null, - paths: string[], - inputMode: IntentInputMode, - outputMode: IntentOutputMode, - fieldSpecs: GeneratedSchemaField[], -): Array<[string, string]> { - const parts = ['transloadit', ...paths] - - if (inputMode === 'local-files' && definition != null) { - parts.push('--input', guessInputFile(definition.meta)) - } - - if (inputMode === 'none' && definition != null) { - parts.push('--prompt', JSON.stringify(guessPromptExample(definition.robot))) - } - - if (definition != null) { - for (const fieldSpec of fieldSpecs) { - if (!fieldSpec.required) continue - if (fieldSpec.name === 'prompt' && inputMode === 'none') continue - - const exampleValue = inferExampleValue(definition, fieldSpec) - if (exampleValue == null) continue - parts.push(fieldSpec.optionFlags, exampleValue) - } - } - - parts.push('--out', guessOutputPath(definition, paths, outputMode)) - - return [['Run the command', parts.join(' ')]] -} - function collectSchemaFields( - schemaSpec: ResolvedIntentSchemaSpec, + schemaShape: Record, fixedValues: Record, input: ResolvedIntentInput, ): GeneratedSchemaField[] { - const shape = (schemaSpec.schema as ZodObject).shape as Record - - return Object.entries(shape) + return Object.entries(schemaShape) .filter(([key]) => !hiddenFieldNames.has(key) && !Object.hasOwn(fixedValues, key)) .flatMap(([key, fieldSchema]) => { const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) - let kind: GeneratedFieldKind + let kind: IntentFieldKind try { - kind = getFieldKind(unwrappedSchema) + kind = inferIntentFieldKind(unwrappedSchema) } catch { return [] } - const required = (input.kind === 'none' && key === 'prompt') || schemaRequired - return [ { name: key, propertyName: toCamelCase(key), optionFlags: `--${toKebabCase(key)}`, - required, + required: (input.kind === 'none' && key === 'prompt') || schemaRequired, description: fieldSchema.description, kind, }, @@ -498,54 +347,130 @@ function collectSchemaFields( }) } -function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { +function analyzeRobotIntent(definition: RobotIntentDefinition): RobotIntentAnalysis { const paths = getIntentPaths(definition) - const inputMode = inferInputMode(definition) - const outputMode = inferOutputMode(definition) - const input = inferInputSpec(definition) + const commandLabel = paths.join(' ') + const className = `${toPascalCase(paths)}Command` + const outputMode = definition.outputMode ?? 'file' const schemaSpec = { importName: definition.schemaImportName, importPath: definition.schemaImportPath, schema: definition.schema as ZodObject, } satisfies ResolvedIntentSchemaSpec + const schemaShape = schemaSpec.schema.shape as Record + const inputMode = definition.inputMode ?? inferInputModeFromShape(schemaShape) + const promptIsOptional = 'prompt' in schemaShape && !unwrapSchema(schemaShape.prompt).required + const requiredFieldForInputless = promptIsOptional ? 'prompt' : undefined + const input = inferInputSpecFromAnalysis({ + defaultSingleAssembly: definition.defaultSingleAssembly, + inputMode, + requiredFieldForInputless, + }) const execution = { kind: 'single-step', - resultStepName: inferResultStepName(definition.robot), - fixedValues: inferFixedValues(definition, inputMode), + resultStepName: + getIntentResultStepName(definition) ?? + (() => { + throw new Error(`Could not infer result step name for "${definition.robot}"`) + })(), + fixedValues: inferFixedValuesFromAnalysis({ + defaultSingleAssembly: definition.defaultSingleAssembly, + inputMode, + promptIsOptional, + robot: definition.robot, + }), } satisfies ResolvedIntentSingleStepExecution - const fieldSpecs = collectSchemaFields(schemaSpec, execution.fixedValues, input) + const fieldSpecs = collectSchemaFields(schemaShape, execution.fixedValues, input) + const description = stripTrailingPunctuation(definition.meta.title) + const details = + inputMode === 'none' + ? `Runs \`${definition.robot}\` and writes the result to \`--out\`.` + : definition.defaultSingleAssembly === true + ? `Runs \`${definition.robot}\` for the provided inputs and writes the result to \`--out\`.` + : outputMode === 'directory' + ? `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` + : `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.` return { - className: inferClassName(paths), - commandLabel: paths.join(' '), - description: inferDescription(definition), - details: inferDetails( - definition, - inputMode, - outputMode, - definition.defaultSingleAssembly === true, - ), - examples: inferExamples(definition, paths, inputMode, outputMode, fieldSpecs), + className, + commandLabel, + definition, + details, + description, execution, fieldSpecs, input, - outputDescription: inferOutputDescription(inputMode, outputMode), + inputMode, + outputDescription: + outputMode === 'directory' + ? 'Write the results to this directory' + : inputMode === 'local-files' + ? 'Write the result to this path or directory' + : 'Write the result to this path', outputMode, - outputRequired: true, paths, + presentation: robotIntentPresentationOverrides[definition.robot] ?? {}, + schemaShape, schemaSpec, } } +function inferExamples(analysis: RobotIntentAnalysis): Array<[string, string]> { + const parts = ['transloadit', ...analysis.paths] + + if (analysis.inputMode === 'local-files') { + parts.push('--input', getTypicalInputFile(analysis.definition.meta)) + } + + if (analysis.inputMode === 'none') { + parts.push('--prompt', JSON.stringify(getDefaultPromptExample(analysis.definition.robot))) + } + + for (const fieldSpec of analysis.fieldSpecs) { + if (!fieldSpec.required) continue + if (fieldSpec.name === 'prompt' && analysis.inputMode === 'none') continue + + const exampleValue = getDefaultRequiredExampleValue(analysis.definition, fieldSpec) + if (exampleValue == null) continue + parts.push(fieldSpec.optionFlags, exampleValue) + } + + parts.push( + '--out', + analysis.presentation.outputPath ?? getDefaultOutputPath(analysis.paths, analysis.outputMode), + ) + + return [['Run the command', parts.join(' ')]] +} + +function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { + const analysis = analyzeRobotIntent(definition) + + return { + className: analysis.className, + commandLabel: analysis.commandLabel, + description: analysis.description, + details: analysis.details, + examples: inferExamples(analysis), + execution: analysis.execution, + fieldSpecs: analysis.fieldSpecs, + input: analysis.input, + outputDescription: analysis.outputDescription, + outputMode: analysis.outputMode, + outputRequired: true, + paths: analysis.paths, + schemaSpec: analysis.schemaSpec, + } +} + function resolveTemplateIntentSpec( definition: IntentDefinition & { kind: 'template' }, ): ResolvedIntentCommandSpec { - const outputMode = inferOutputMode(definition) - const input = inferLocalFilesInput({}) + const outputMode = definition.outputMode ?? 'file' const paths = getIntentPaths(definition) return { - className: inferClassName(paths), + className: `${toPascalCase(paths)}Command`, commandLabel: paths.join(' '), description: `Run ${stripTrailingPunctuation(definition.templateId)}`, details: `Runs the \`${definition.templateId}\` template and writes the outputs to \`--out\`.`, @@ -557,8 +482,11 @@ function resolveTemplateIntentSpec( templateId: definition.templateId, }, fieldSpecs: [], - input, - outputDescription: inferOutputDescription('local-files', outputMode), + input: inferInputSpecFromAnalysis({ inputMode: 'local-files' }), + outputDescription: + outputMode === 'directory' + ? 'Write the results to this directory' + : 'Write the result to this path or directory', outputMode, outputRequired: true, paths, diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 3b43139d..b0f1e481 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -7,13 +7,8 @@ import { prepareInputFiles } from '../inputFiles.ts' import type { AssembliesCreateOptions } from './commands/assemblies.ts' import * as assembliesCommands from './commands/assemblies.ts' import { AuthenticatedCommand } from './commands/BaseCommand.ts' - -export type IntentFieldKind = 'auto' | 'boolean' | 'number' | 'string' - -export interface IntentFieldSpec { - kind: IntentFieldKind - name: string -} +import type { IntentFieldSpec } from './intentFields.ts' +import { coerceIntentFieldValue } from './intentFields.ts' export interface PreparedIntentInputs { cleanup: Array<() => Promise> @@ -159,54 +154,6 @@ export async function prepareIntentInputs({ } } -function coerceIntentFieldValue( - kind: IntentFieldKind, - raw: string, - fieldSchema?: z.ZodTypeAny, -): boolean | number | string { - if (kind === 'auto') { - if (fieldSchema == null) { - return raw - } - - const candidates: unknown[] = [raw] - - if (raw === 'true' || raw === 'false') { - candidates.push(raw === 'true') - } - - const numericValue = Number(raw) - if (raw.trim() !== '' && !Number.isNaN(numericValue)) { - candidates.push(numericValue) - } - - for (const candidate of candidates) { - const parsed = fieldSchema.safeParse(candidate) - if (parsed.success) { - return parsed.data as boolean | number | string - } - } - - return raw - } - - if (kind === 'number') { - const value = Number(raw) - if (Number.isNaN(value)) { - throw new Error(`Expected a number but received "${raw}"`) - } - return value - } - - if (kind === 'boolean') { - if (raw === 'true') return true - if (raw === 'false') return false - throw new Error(`Expected "true" or "false" but received "${raw}"`) - } - - return raw -} - export function parseIntentStep({ fieldSpecs, fixedValues, From bfa854263a1cfc5b6a1751fb0083b903f51a7e92 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 22:18:35 +0200 Subject: [PATCH 20/44] refactor(node): unify cli file processing flow --- .../node/scripts/generate-intent-commands.ts | 14 +- packages/node/src/cli/commands/assemblies.ts | 181 +++++--- .../src/cli/commands/generated-intents.ts | 213 +++++---- .../node/src/cli/fileProcessingOptions.ts | 87 ++++ packages/node/src/cli/intentFields.ts | 62 ++- packages/node/src/cli/intentRuntime.ts | 127 +++--- .../test/unit/cli/assemblies-create.test.ts | 407 ++++++++++++++++++ packages/node/test/unit/cli/intents.test.ts | 169 ++++++++ scripts/prepare-transloadit.ts | 51 ++- 9 files changed, 1093 insertions(+), 218 deletions(-) create mode 100644 packages/node/src/cli/fileProcessingOptions.ts diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 6552c601..c7c3a8f2 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -104,8 +104,9 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { : '' const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` + const outputLines = `\n outputDescription: ${JSON.stringify(spec.outputDescription)},\n outputRequired: ${JSON.stringify(spec.outputRequired)},` - return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${requiredField}${outputMode} + return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${requiredField}${outputMode}${outputLines} execution: { kind: 'single-step', schema: ${spec.schemaSpec?.importName}, @@ -120,6 +121,8 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` return `const ${getCommandDefinitionName(spec)} = { commandLabel: ${JSON.stringify(spec.commandLabel)},${outputMode} + outputDescription: ${JSON.stringify(spec.outputDescription)}, + outputRequired: ${JSON.stringify(spec.outputRequired)}, execution: { kind: 'template', templateId: ${JSON.stringify(spec.execution.templateId)}, @@ -151,12 +154,9 @@ ${formatUsageExamples(spec.examples)} ], }) - protected override readonly intentDefinition = ${getCommandDefinitionName(spec)} - - override outputPath = Option.String('--out,-o', { - description: ${JSON.stringify(spec.outputDescription)}, - required: ${spec.outputRequired}, - }) + protected override getIntentDefinition() { + return ${getCommandDefinitionName(spec)} + } ${schemaFields}${schemaFields ? '\n\n' : ''}${rawValuesMethod} } diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index d61bf85a..2f9a479f 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -5,6 +5,7 @@ import fsp from 'node:fs/promises' import path from 'node:path' import process from 'node:process' import type { Readable } from 'node:stream' +import { Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { setTimeout as delay } from 'node:timers/promises' import tty from 'node:tty' @@ -23,6 +24,16 @@ import type { LintFatalLevel } from '../../lintAssemblyInstructions.ts' import { lintAssemblyInstructions } from '../../lintAssemblyInstructions.ts' import type { CreateAssemblyOptions, Transloadit } from '../../Transloadit.ts' import { lintingExamples } from '../docs/assemblyLintingExamples.ts' +import { + concurrencyOption, + deleteAfterProcessingOption, + inputPathsOption, + recursiveOption, + reprocessStaleOption, + singleAssemblyOption, + validateSharedFileProcessingOptions, + watchOption, +} from '../fileProcessingOptions.ts' import { createReadStream, formatAPIError, readCliInput, streamToBuffer } from '../helpers.ts' import type { IOutputCtl } from '../OutputCtl.ts' import { ensureError, isErrnoException } from '../types.ts' @@ -445,6 +456,43 @@ async function downloadResultToFile( await fsp.rename(tempPath, outPath) } +async function downloadResultToStdout(resultUrl: string, signal: AbortSignal): Promise { + const stdoutStream = new Writable({ + write(chunk, _encoding, callback) { + let settled = false + + const finish = (err?: Error | null) => { + if (settled) return + settled = true + process.stdout.off('drain', onDrain) + process.stdout.off('error', onError) + callback(err ?? undefined) + } + + const onDrain = () => finish() + const onError = (err: Error) => finish(err) + + process.stdout.once('error', onError) + + try { + if (process.stdout.write(chunk)) { + finish() + return + } + + process.stdout.once('drain', onDrain) + } catch (err) { + finish(ensureError(err)) + } + }, + final(callback) { + callback() + }, + }) + + await pipeline(got.stream(resultUrl, { signal }), stdoutStream) +} + interface AssemblyResultFile { file: { basename?: string | null @@ -465,15 +513,19 @@ function sanitizeResultName(value: string): string { return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '') } -async function ensureUniquePath(targetPath: string): Promise { +async function ensureUniquePath(targetPath: string, reservedPaths: Set): Promise { const parsed = path.parse(targetPath) let candidate = targetPath let counter = 1 while (true) { - const [statErr] = await tryCatch(fsp.stat(candidate)) - if (statErr) { - return candidate + if (!reservedPaths.has(candidate)) { + const [statErr] = await tryCatch(fsp.stat(candidate)) + if (statErr) { + reservedPaths.add(candidate) + return candidate + } } + candidate = path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`) counter += 1 } @@ -505,7 +557,7 @@ function getResultFileName({ file, stepName }: AssemblyResultFile): string { interface AssemblyDownloadTarget { resultUrl: string - targetPath: string + targetPath: string | null } async function buildDirectoryDownloadTargets({ @@ -520,6 +572,7 @@ async function buildDirectoryDownloadTargets({ await fsp.mkdir(baseDir, { recursive: true }) const targets: AssemblyDownloadTarget[] = [] + const reservedPaths = new Set() for (const resultFile of allFiles) { const resultUrl = getResultFileUrl(resultFile.file) if (resultUrl == null) { @@ -531,7 +584,10 @@ async function buildDirectoryDownloadTargets({ targets.push({ resultUrl, - targetPath: await ensureUniquePath(path.join(targetDir, getResultFileName(resultFile))), + targetPath: await ensureUniquePath( + path.join(targetDir, getResultFileName(resultFile)), + reservedPaths, + ), }) } @@ -580,13 +636,17 @@ async function resolveResultDownloadTargets({ } if (!outputRootIsDirectory) { - if (outputPath == null) { - return [] + if (outputPath == null && allFiles.length > 1) { + throw new Error('stdout can only receive a single result file') } const first = allFiles[0] const resultUrl = first == null ? null : getResultFileUrl(first.file) - return resultUrl == null ? [] : [{ resultUrl, targetPath: outputPath }] + if (resultUrl == null) { + return [] + } + + return [{ resultUrl, targetPath: outputPath }] } if (singleAssembly) { @@ -663,7 +723,11 @@ async function materializeAssemblyResults({ for (const { resultUrl, targetPath } of targets) { outputctl.debug('DOWNLOADING') - const [dlErr] = await tryCatch(downloadResultToFile(resultUrl, targetPath, abortSignal)) + const [dlErr] = await tryCatch( + targetPath == null + ? downloadResultToStdout(resultUrl, abortSignal) + : downloadResultToFile(resultUrl, targetPath, abortSignal), + ) if (dlErr) { if (dlErr.name === 'AbortError') { continue @@ -767,14 +831,20 @@ class SingleJobEmitter extends MyEventEmitter { super() const normalizedFile = path.normalize(file) - outputPlanProvider(normalizedFile).then((outputPlan) => { - const instream = createInputJobStream(normalizedFile) + outputPlanProvider(normalizedFile) + .then((outputPlan) => { + const instream = createInputJobStream(normalizedFile) - process.nextTick(() => { - this.emit('job', { in: instream, out: outputPlan }) - this.emit('end') + process.nextTick(() => { + this.emit('job', { in: instream, out: outputPlan }) + this.emit('end') + }) + }) + .catch((err: unknown) => { + process.nextTick(() => { + this.emit('error', ensureError(err)) + }) }) - }) } } @@ -783,15 +853,20 @@ class InputlessJobEmitter extends MyEventEmitter { super() process.nextTick(() => { - outputPlanProvider(null).then((outputPlan) => { - try { - this.emit('job', { in: null, out: outputPlan }) - } catch (err) { - this.emit('error', err) - } + outputPlanProvider(null) + .then((outputPlan) => { + try { + this.emit('job', { in: null, out: outputPlan }) + } catch (err) { + this.emit('error', ensureError(err)) + return + } - this.emit('end') - }) + this.emit('end') + }) + .catch((err: unknown) => { + this.emit('error', ensureError(err)) + }) }) } } @@ -1262,6 +1337,16 @@ export async function create( const assembly = await awaitCompletedAssembly(createOptions) if (!assembly.results) throw new Error('No results in assembly') + if ( + !singleAssemblyMode && + outputPlan?.path != null && + !outputRootIsDirectory && + ((await tryCatch(fsp.stat(outputPlan.path)))[1]?.mtime ?? new Date(0)) > outputPlan.mtime + ) { + outputctl.debug(`SKIPPED STALE RESULT ${inPath ?? 'null'} ${outputPlan.path}`) + return assembly + } + await materializeAssemblyResults({ abortSignal: abortController.signal, hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput, @@ -1280,6 +1365,9 @@ export async function create( if (del) { for (const inputPath of inputPaths) { + if (inputPath === stdinWithPath.path) { + continue + } await fsp.unlink(inputPath) } } @@ -1450,9 +1538,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { description: 'Specify a template to use for these assemblies', }) - inputs = Option.Array('--input,-i', { - description: 'Provide an input file or a directory', - }) + inputs = inputPathsOption() outputPath = Option.String('--output,-o', { description: 'Specify an output file or directory', @@ -1462,30 +1548,17 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { description: 'Set a template field (KEY=VAL)', }) - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) + watch = watchOption() - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) + recursive = recursiveOption() - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) + deleteAfterProcessing = deleteAfterProcessingOption() - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) + reprocessStale = reprocessStaleOption() - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) + singleAssembly = singleAssemblyOption() - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) + concurrency = concurrencyOption() protected async run(): Promise { if (!this.steps && !this.template) { @@ -1498,10 +1571,6 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { } const inputList = this.inputs ?? [] - if (inputList.length === 0 && this.watch) { - this.output.error('assemblies create --watch requires at least one input') - return 1 - } // Default to stdin only for `--steps` mode (common "pipe a file into a one-off assembly" use case). // For `--template` mode, templates may be inputless or use /http/import, so stdin should be explicit (`--input -`). @@ -1521,8 +1590,14 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { fieldsMap[key] = value } - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') + const sharedValidationError = validateSharedFileProcessingOptions({ + explicitInputCount: this.inputs?.length ?? 0, + singleAssembly: this.singleAssembly, + watch: this.watch, + watchRequiresInputsMessage: 'assemblies create --watch requires at least one input', + }) + if (sharedValidationError != null) { + this.output.error(sharedValidationError) return 1 } @@ -1537,7 +1612,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { del: this.deleteAfterProcessing, reprocessStale: this.reprocessStale, singleAssembly: this.singleAssembly, - concurrency: this.concurrency, + concurrency: this.concurrency == null ? undefined : Number(this.concurrency), }) return hasFailures ? 1 : undefined } diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index 255ca4c6..a440e9f2 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -25,6 +25,8 @@ import { const imageGenerateCommandDefinition = { outputMode: 'file', + outputDescription: 'Write the result to this path', + outputRequired: true, execution: { kind: 'single-step', schema: robotImageGenerateInstructionsSchema, @@ -50,6 +52,8 @@ const imageGenerateCommandDefinition = { const previewGenerateCommandDefinition = { commandLabel: 'preview generate', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotFilePreviewInstructionsSchema, @@ -59,6 +63,7 @@ const previewGenerateCommandDefinition = { { name: 'height', kind: 'number' }, { name: 'resize_strategy', kind: 'string' }, { name: 'background', kind: 'string' }, + { name: 'strategy', kind: 'json' }, { name: 'artwork_outer_color', kind: 'string' }, { name: 'artwork_center_color', kind: 'string' }, { name: 'waveform_center_color', kind: 'string' }, @@ -90,6 +95,8 @@ const previewGenerateCommandDefinition = { const imageRemoveBackgroundCommandDefinition = { commandLabel: 'image remove-background', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotImageBgremoveInstructionsSchema, @@ -111,6 +118,8 @@ const imageRemoveBackgroundCommandDefinition = { const imageOptimizeCommandDefinition = { commandLabel: 'image optimize', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotImageOptimizeInstructionsSchema, @@ -132,6 +141,8 @@ const imageOptimizeCommandDefinition = { const imageResizeCommandDefinition = { commandLabel: 'image resize', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotImageResizeInstructionsSchema, @@ -141,6 +152,7 @@ const imageResizeCommandDefinition = { { name: 'height', kind: 'number' }, { name: 'resize_strategy', kind: 'string' }, { name: 'zoom', kind: 'boolean' }, + { name: 'crop', kind: 'auto' }, { name: 'gravity', kind: 'string' }, { name: 'strip', kind: 'boolean' }, { name: 'alpha', kind: 'string' }, @@ -157,11 +169,13 @@ const imageResizeCommandDefinition = { { name: 'rotation', kind: 'auto' }, { name: 'compress', kind: 'string' }, { name: 'blur', kind: 'string' }, + { name: 'blur_regions', kind: 'json' }, { name: 'brightness', kind: 'number' }, { name: 'saturation', kind: 'number' }, { name: 'hue', kind: 'number' }, { name: 'contrast', kind: 'number' }, { name: 'watermark_url', kind: 'string' }, + { name: 'watermark_position', kind: 'auto' }, { name: 'watermark_x_offset', kind: 'number' }, { name: 'watermark_y_offset', kind: 'number' }, { name: 'watermark_size', kind: 'string' }, @@ -169,6 +183,7 @@ const imageResizeCommandDefinition = { { name: 'watermark_opacity', kind: 'number' }, { name: 'watermark_repeat_x', kind: 'boolean' }, { name: 'watermark_repeat_y', kind: 'boolean' }, + { name: 'text', kind: 'json' }, { name: 'progressive', kind: 'boolean' }, { name: 'transparent', kind: 'string' }, { name: 'trim_whitespace', kind: 'boolean' }, @@ -190,6 +205,8 @@ const imageResizeCommandDefinition = { const documentConvertCommandDefinition = { commandLabel: 'document convert', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentConvertInstructionsSchema, @@ -216,6 +233,8 @@ const documentConvertCommandDefinition = { const documentOptimizeCommandDefinition = { commandLabel: 'document optimize', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentOptimizeInstructionsSchema, @@ -240,6 +259,8 @@ const documentOptimizeCommandDefinition = { const documentAutoRotateCommandDefinition = { commandLabel: 'document auto-rotate', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentAutorotateInstructionsSchema, @@ -256,6 +277,8 @@ const documentAutoRotateCommandDefinition = { const documentThumbsCommandDefinition = { commandLabel: 'document thumbs', outputMode: 'directory', + outputDescription: 'Write the results to this directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentThumbsInstructionsSchema, @@ -287,10 +310,13 @@ const documentThumbsCommandDefinition = { const audioWaveformCommandDefinition = { commandLabel: 'audio waveform', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotAudioWaveformInstructionsSchema, fieldSpecs: [ + { name: 'ffmpeg', kind: 'json' }, { name: 'format', kind: 'string' }, { name: 'width', kind: 'number' }, { name: 'height', kind: 'number' }, @@ -330,6 +356,8 @@ const textSpeakCommandDefinition = { commandLabel: 'text speak', requiredFieldForInputless: 'prompt', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotTextSpeakInstructionsSchema, @@ -352,11 +380,15 @@ const textSpeakCommandDefinition = { const videoThumbsCommandDefinition = { commandLabel: 'video thumbs', outputMode: 'directory', + outputDescription: 'Write the results to this directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotVideoThumbsInstructionsSchema, fieldSpecs: [ + { name: 'ffmpeg', kind: 'json' }, { name: 'count', kind: 'number' }, + { name: 'offsets', kind: 'json' }, { name: 'format', kind: 'string' }, { name: 'width', kind: 'number' }, { name: 'height', kind: 'number' }, @@ -377,6 +409,8 @@ const videoThumbsCommandDefinition = { const videoEncodeHlsCommandDefinition = { commandLabel: 'video encode-hls', outputMode: 'directory', + outputDescription: 'Write the results to this directory', + outputRequired: true, execution: { kind: 'template', templateId: 'builtin/encode-hls-video@latest', @@ -386,6 +420,8 @@ const videoEncodeHlsCommandDefinition = { const fileCompressCommandDefinition = { commandLabel: 'file compress', outputMode: 'file', + outputDescription: 'Write the result to this path or directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotFileCompressInstructionsSchema, @@ -412,6 +448,8 @@ const fileCompressCommandDefinition = { const fileDecompressCommandDefinition = { commandLabel: 'file decompress', outputMode: 'directory', + outputDescription: 'Write the results to this directory', + outputRequired: true, execution: { kind: 'single-step', schema: robotFileDecompressInstructionsSchema, @@ -440,12 +478,9 @@ class ImageGenerateCommand extends GeneratedNoInputIntentCommand { ], }) - protected override readonly intentDefinition = imageGenerateCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path', - required: true, - }) + protected override getIntentDefinition() { + return imageGenerateCommandDefinition + } model = Option.String('--model', { description: 'The AI model to use for image generation. Defaults to google/nano-banana.', @@ -511,12 +546,9 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = previewGenerateCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return previewGenerateCommandDefinition + } format = Option.String('--format', { description: @@ -541,6 +573,11 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { 'The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). The format is `#rrggbb[aa]` (red, green, blue, alpha). Use `#00000000` for a transparent padding.', }) + strategy = Option.String('--strategy', { + description: + 'Definition of the thumbnail generation process per file category. The parameter must be an object whose keys can be one of the file categories: `audio`, `video`, `image`, `document`, `archive`, `webpage`, and `unknown`. The corresponding value is an array of strategies for the specific file category. See the above section for a list of all available strategies.\n\nFor each file, the Robot will attempt to use the first strategy to generate the thumbnail. If this process fails (e.g., because no artwork is available in a video file), the next strategy is attempted. This is repeated until either a thumbnail is generated or the list is exhausted. Selecting the `icon` strategy as the last entry provides a fallback mechanism to ensure that an appropriate strategy is always available.\n\nThe parameter defaults to the following definition:\n\n```json\n{\n "audio": ["artwork", "waveform", "icon"],\n "video": ["artwork", "frame", "icon"],\n "document": ["page", "icon"],\n "image": ["image", "icon"],\n "webpage": ["render", "icon"],\n "archive": ["icon"],\n "unknown": ["icon"]\n}\n```', + }) + artworkOuterColor = Option.String('--artwork-outer-color', { description: "The color used in the outer parts of the artwork's gradient.", }) @@ -636,6 +673,7 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { height: this.height, resize_strategy: this.resizeStrategy, background: this.background, + strategy: this.strategy, artwork_outer_color: this.artworkOuterColor, artwork_center_color: this.artworkCenterColor, waveform_center_color: this.waveformCenterColor, @@ -670,12 +708,9 @@ class ImageRemoveBackgroundCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = imageRemoveBackgroundCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return imageRemoveBackgroundCommandDefinition + } select = Option.String('--select', { description: 'Region to select and keep in the image. The other region is removed.', @@ -716,12 +751,9 @@ class ImageOptimizeCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = imageOptimizeCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return imageOptimizeCommandDefinition + } priority = Option.String('--priority', { description: @@ -763,12 +795,9 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit image resize --input input.png --out output.png']], }) - protected override readonly intentDefinition = imageResizeCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return imageResizeCommandDefinition + } format = Option.String('--format', { description: @@ -794,6 +823,11 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'If this is set to `false`, smaller images will not be stretched to the desired width and height. For details about the impact of zooming for your preferred resize strategy, see the list of available [resize strategies](/docs/topics/resize-strategies/).', }) + crop = Option.String('--crop', { + description: + 'Specify an object containing coordinates for the top left and bottom right corners of the rectangle to be cropped from the original image(s). The coordinate system is rooted in the top left corner of the image. Values can be integers for absolute pixel values or strings for percentage based values.\n\nFor example:\n\n```json\n{\n "x1": 80,\n "y1": 100,\n "x2": "60%",\n "y2": "80%"\n}\n```\n\nThis will crop the area from `(80, 100)` to `(600, 800)` from a 1000×1000 pixels image, which is a square whose width is 520px and height is 700px. If `crop` is set, the width and height parameters are ignored, and the `resize_strategy` is set to `crop` automatically.\n\nYou can also use a JSON string of such an object with coordinates in similar fashion:\n\n```json\n"{\\"x1\\": , \\"y1\\": , \\"x2\\": , \\"y2\\": }"\n```\n\nTo crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/).', + }) + gravity = Option.String('--gravity', { description: 'The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined.', @@ -872,6 +906,11 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'Specifies gaussian blur, using a value with the form `{radius}x{sigma}`. The radius value specifies the size of area the operator should look at when spreading pixels, and should typically be either `"0"` or at least two times the sigma value. The sigma value is an approximation of how many pixels the image is "spread"; think of it as the size of the brush used to blur the image. This number is a floating point value, enabling small values like `"0.5"` to be used.', }) + blurRegions = Option.String('--blur-regions', { + description: + 'Specifies an array of ellipse objects that should be blurred on the image. Each object has the following keys: `x`, `y`, `width`, `height`. If `blur_regions` has a value, then the `blur` parameter is used as the strength of the blur for each region.', + }) + brightness = Option.String('--brightness', { description: 'Increases or decreases the brightness of the image by using a multiplier. For example `1.5` would increase the brightness by 50%, and `0.75` would decrease the brightness by 25%.', @@ -897,6 +936,11 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'A URL indicating a PNG image to be overlaid above this image. Please note that you can also [supply the watermark via another Assembly Step](/docs/topics/use-parameter/#supplying-the-watermark-via-an-assembly-step). With watermarking you can add an image onto another image. This is usually used for logos.', }) + watermarkPosition = Option.String('--watermark-position', { + description: + 'The position at which the watermark is placed. The available options are `"center"`, `"top"`, `"bottom"`, `"left"`, and `"right"`. You can also combine options, such as `"bottom-right"`.\n\nAn array of possible values can also be specified, in which case one value will be selected at random, such as `[ "center", "left", "bottom-left", "bottom-right" ]`.\n\nThis setting puts the watermark in the specified corner. To use a specific pixel offset for the watermark, you will need to add the padding to the image itself.', + }) + watermarkXOffset = Option.String('--watermark-x-offset', { description: "The x-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", @@ -932,6 +976,11 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'When set to `true`, the watermark will be repeated vertically across the entire height of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image. Can be combined with `watermark_repeat_x` to tile in both directions.', }) + text = Option.String('--text', { + description: + 'Text overlays to be applied to the image. Can be either a single text object or an array of text objects. Each text object contains text rules. The following text parameters are intended to be used as properties for your text overlays. Here is an example:\n\n```json\n"watermarked": {\n "use": "resized",\n "robot": "/image/resize",\n "text": [\n {\n "text": "© 2018 Transloadit.com",\n "size": 12,\n "font": "Ubuntu",\n "color": "#eeeeee",\n "valign": "bottom",\n "align": "right",\n "x_offset": 16,\n "y_offset": -10\n }\n ]\n}\n```', + }) + progressive = Option.String('--progressive', { description: 'Interlaces the image if set to `true`, which makes the image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the images which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a cost of a file size increase by around 10%.', @@ -978,6 +1027,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { height: this.height, resize_strategy: this.resizeStrategy, zoom: this.zoom, + crop: this.crop, gravity: this.gravity, strip: this.strip, alpha: this.alpha, @@ -994,11 +1044,13 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { rotation: this.rotation, compress: this.compress, blur: this.blur, + blur_regions: this.blurRegions, brightness: this.brightness, saturation: this.saturation, hue: this.hue, contrast: this.contrast, watermark_url: this.watermarkUrl, + watermark_position: this.watermarkPosition, watermark_x_offset: this.watermarkXOffset, watermark_y_offset: this.watermarkYOffset, watermark_size: this.watermarkSize, @@ -1006,6 +1058,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { watermark_opacity: this.watermarkOpacity, watermark_repeat_x: this.watermarkRepeatX, watermark_repeat_y: this.watermarkRepeatY, + text: this.text, progressive: this.progressive, transparent: this.transparent, trim_whitespace: this.trimWhitespace, @@ -1033,12 +1086,9 @@ class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = documentConvertCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return documentConvertCommandDefinition + } format = Option.String('--format', { description: 'The desired format for document conversion.', @@ -1112,12 +1162,9 @@ class DocumentOptimizeCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = documentOptimizeCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return documentOptimizeCommandDefinition + } preset = Option.String('--preset', { description: @@ -1179,12 +1226,9 @@ class DocumentAutoRotateCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = documentAutoRotateCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return documentAutoRotateCommandDefinition + } protected override getIntentRawValues(): Record { return {} @@ -1201,12 +1245,9 @@ class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit document thumbs --input input.pdf --out output/']], }) - protected override readonly intentDefinition = documentThumbsCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the results to this directory', - required: true, - }) + protected override getIntentDefinition() { + return documentThumbsCommandDefinition + } page = Option.String('--page', { description: @@ -1309,11 +1350,13 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = audioWaveformCommandDefinition + protected override getIntentDefinition() { + return audioWaveformCommandDefinition + } - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, + ffmpeg = Option.String('--ffmpeg', { + description: + 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', }) format = Option.String('--format', { @@ -1433,6 +1476,7 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { protected override getIntentRawValues(): Record { return { + ffmpeg: this.ffmpeg, format: this.format, width: this.width, height: this.height, @@ -1477,12 +1521,9 @@ class TextSpeakCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override readonly intentDefinition = textSpeakCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return textSpeakCommandDefinition + } prompt = Option.String('--prompt', { description: @@ -1531,11 +1572,13 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit video thumbs --input input.mp4 --out output/']], }) - protected override readonly intentDefinition = videoThumbsCommandDefinition + protected override getIntentDefinition() { + return videoThumbsCommandDefinition + } - override outputPath = Option.String('--out,-o', { - description: 'Write the results to this directory', - required: true, + ffmpeg = Option.String('--ffmpeg', { + description: + 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', }) count = Option.String('--count', { @@ -1543,6 +1586,11 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { 'The number of thumbnails to be extracted. As some videos have incorrect durations, the actual number of thumbnails generated may be less in rare cases. The maximum number of thumbnails we currently allow is 999.\n\nThe thumbnails are taken at regular intervals, determined by dividing the video duration by the count. For example, a count of 3 will produce thumbnails at 25%, 50% and 75% through the video.\n\nTo extract thumbnails for specific timestamps, use the `offsets` parameter.', }) + offsets = Option.String('--offsets', { + description: + 'An array of offsets representing seconds of the file duration, such as `[ 2, 45, 120 ]`. Millisecond durations of a file can also be used by using decimal place values. For example, an offset from 1250 milliseconds would be represented with `1.25`. Offsets can also be percentage values such as `[ "2%", "50%", "75%" ]`.\n\nThis option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored.', + }) + format = Option.String('--format', { description: 'The format of the extracted thumbnail. Supported values are `"jpg"`, `"jpeg"` and `"png"`. Even if you specify the format to be `"jpeg"` the resulting thumbnails will have a `"jpg"` file extension.', @@ -1579,7 +1627,9 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { protected override getIntentRawValues(): Record { return { + ffmpeg: this.ffmpeg, count: this.count, + offsets: this.offsets, format: this.format, width: this.width, height: this.height, @@ -1602,12 +1652,9 @@ class VideoEncodeHlsCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit video encode-hls --input input.mp4 --out output/']], }) - protected override readonly intentDefinition = videoEncodeHlsCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the results to this directory', - required: true, - }) + protected override getIntentDefinition() { + return videoEncodeHlsCommandDefinition + } protected override getIntentRawValues(): Record { return {} @@ -1626,12 +1673,9 @@ class FileCompressCommand extends GeneratedBundledFileIntentCommand { ], }) - protected override readonly intentDefinition = fileCompressCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the result to this path or directory', - required: true, - }) + protected override getIntentDefinition() { + return fileCompressCommandDefinition + } format = Option.String('--format', { description: @@ -1684,12 +1728,9 @@ class FileDecompressCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit file decompress --input input.file --out output/']], }) - protected override readonly intentDefinition = fileDecompressCommandDefinition - - override outputPath = Option.String('--out,-o', { - description: 'Write the results to this directory', - required: true, - }) + protected override getIntentDefinition() { + return fileDecompressCommandDefinition + } protected override getIntentRawValues(): Record { return {} diff --git a/packages/node/src/cli/fileProcessingOptions.ts b/packages/node/src/cli/fileProcessingOptions.ts new file mode 100644 index 00000000..bce56c92 --- /dev/null +++ b/packages/node/src/cli/fileProcessingOptions.ts @@ -0,0 +1,87 @@ +import { Option } from 'clipanion' +import * as t from 'typanion' + +export interface SharedFileProcessingValidationInput { + explicitInputCount: number + singleAssembly: boolean + watch: boolean + watchRequiresInputsMessage: string +} + +export function inputPathsOption(description = 'Provide an input file or a directory'): string[] { + return Option.Array('--input,-i', { + description, + }) as unknown as string[] +} + +export function recursiveOption(description = 'Enumerate input directories recursively'): boolean { + return Option.Boolean('--recursive,-r', false, { + description, + }) as unknown as boolean +} + +export function deleteAfterProcessingOption( + description = 'Delete input files after they are processed', +): boolean { + return Option.Boolean('--delete-after-processing,-d', false, { + description, + }) as unknown as boolean +} + +export function reprocessStaleOption( + description = 'Process inputs even if output is newer', +): boolean { + return Option.Boolean('--reprocess-stale', false, { + description, + }) as unknown as boolean +} + +export function watchOption(description = 'Watch inputs for changes'): boolean { + return Option.Boolean('--watch,-w', false, { + description, + }) as unknown as boolean +} + +export function singleAssemblyOption( + description = 'Pass all input files to a single assembly instead of one assembly per file', +): boolean { + return Option.Boolean('--single-assembly', false, { + description, + }) as unknown as boolean +} + +export function concurrencyOption( + description = 'Maximum number of concurrent assemblies (default: 5)', +): string | undefined { + return Option.String('--concurrency,-c', { + description, + validator: t.isNumber(), + }) as unknown as string | undefined +} + +export function countProvidedInputs({ + inputBase64, + inputs, +}: { + inputBase64?: string[] + inputs?: string[] +}): number { + return (inputs ?? []).length + (inputBase64 ?? []).length +} + +export function validateSharedFileProcessingOptions({ + explicitInputCount, + singleAssembly, + watch, + watchRequiresInputsMessage, +}: SharedFileProcessingValidationInput): string | undefined { + if (watch && explicitInputCount === 0) { + return watchRequiresInputsMessage + } + + if (watch && singleAssembly) { + return '--single-assembly cannot be used with --watch' + } + + return undefined +} diff --git a/packages/node/src/cli/intentFields.ts b/packages/node/src/cli/intentFields.ts index 4b9af272..6b706206 100644 --- a/packages/node/src/cli/intentFields.ts +++ b/packages/node/src/cli/intentFields.ts @@ -1,7 +1,17 @@ import type { z } from 'zod' -import { ZodBoolean, ZodEffects, ZodEnum, ZodLiteral, ZodNumber, ZodString, ZodUnion } from 'zod' - -export type IntentFieldKind = 'auto' | 'boolean' | 'number' | 'string' +import { + ZodArray, + ZodBoolean, + ZodEffects, + ZodEnum, + ZodLiteral, + ZodNumber, + ZodObject, + ZodString, + ZodUnion, +} from 'zod' + +export type IntentFieldKind = 'auto' | 'boolean' | 'json' | 'number' | 'string' export interface IntentFieldSpec { kind: IntentFieldKind @@ -31,6 +41,10 @@ export function inferIntentFieldKind(schema: unknown): IntentFieldKind { return 'string' } + if (schema instanceof ZodArray || schema instanceof ZodObject) { + return 'json' + } + if (schema instanceof ZodUnion) { const optionKinds = Array.from( new Set(schema._def.options.map((option: unknown) => inferIntentFieldKind(option))), @@ -49,13 +63,28 @@ export function coerceIntentFieldValue( kind: IntentFieldKind, raw: string, fieldSchema?: z.ZodTypeAny, -): boolean | number | string { +): unknown { if (kind === 'auto') { if (fieldSchema == null) { return raw } - const candidates: unknown[] = [raw] + const trimmed = raw.trim() + const candidates: unknown[] = [] + + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + candidates.push(JSON.parse(trimmed)) + } catch {} + } + + candidates.push(raw) + + if (trimmed !== '' && !trimmed.startsWith('{') && !trimmed.startsWith('[')) { + try { + candidates.push(JSON.parse(trimmed)) + } catch {} + } if (raw === 'true' || raw === 'false') { candidates.push(raw === 'true') @@ -77,6 +106,9 @@ export function coerceIntentFieldValue( } if (kind === 'number') { + if (raw.trim() === '') { + throw new Error(`Expected a number but received "${raw}"`) + } const value = Number(raw) if (Number.isNaN(value)) { throw new Error(`Expected a number but received "${raw}"`) @@ -84,6 +116,26 @@ export function coerceIntentFieldValue( return value } + if (kind === 'json') { + let parsedJson: unknown + try { + parsedJson = JSON.parse(raw) + } catch { + throw new Error(`Expected valid JSON but received "${raw}"`) + } + + if (fieldSchema == null) { + return parsedJson + } + + const parsed = fieldSchema.safeParse(parsedJson) + if (!parsed.success) { + throw new Error(parsed.error.message) + } + + return parsed.data + } + if (kind === 'boolean') { if (raw === 'true') return true if (raw === 'false') return false diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index b0f1e481..3b421b64 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -1,12 +1,22 @@ import { basename } from 'node:path' import { Option } from 'clipanion' -import * as t from 'typanion' import type { z } from 'zod' import { prepareInputFiles } from '../inputFiles.ts' import type { AssembliesCreateOptions } from './commands/assemblies.ts' import * as assembliesCommands from './commands/assemblies.ts' import { AuthenticatedCommand } from './commands/BaseCommand.ts' +import { + concurrencyOption, + countProvidedInputs, + deleteAfterProcessingOption, + inputPathsOption, + recursiveOption, + reprocessStaleOption, + singleAssemblyOption, + validateSharedFileProcessingOptions, + watchOption, +} from './fileProcessingOptions.ts' import type { IntentFieldSpec } from './intentFields.ts' import { coerceIntentFieldValue } from './intentFields.ts' @@ -37,13 +47,17 @@ export type IntentFileExecutionDefinition = export interface IntentFileCommandDefinition { commandLabel: string execution: IntentFileExecutionDefinition + outputDescription: string outputMode?: 'directory' | 'file' + outputRequired: boolean requiredFieldForInputless?: string } export interface IntentNoInputCommandDefinition { execution: IntentSingleStepExecutionDefinition + outputDescription: string outputMode?: 'directory' | 'file' + outputRequired: boolean } function isHttpUrl(value: string): boolean { @@ -142,6 +156,7 @@ export async function prepareIntentInputs({ } }), base64Strategy: 'tempfile', + allowPrivateUrls: false, urlStrategy: 'download', }) @@ -239,48 +254,59 @@ async function executeFileIntentCommand({ outputPath: string rawValues: Record }): Promise { - if (definition.execution.kind === 'template') { - const { hasFailures } = await assembliesCommands.create(output, client, { - ...createOptions, - template: definition.execution.templateId, - output: outputPath, - outputMode: definition.outputMode, - }) - return hasFailures ? 1 : undefined - } + const executionOptions = + definition.execution.kind === 'template' + ? { + template: definition.execution.templateId, + } + : { + stepsData: { + [definition.execution.resultStepName]: createSingleStep( + definition.execution, + rawValues, + createOptions.inputs.length > 0, + ), + } as AssembliesCreateOptions['stepsData'], + } - const step = createSingleStep(definition.execution, rawValues, createOptions.inputs.length > 0) const { hasFailures } = await assembliesCommands.create(output, client, { ...createOptions, output: outputPath, outputMode: definition.outputMode, - stepsData: { - [definition.execution.resultStepName]: step, - } as AssembliesCreateOptions['stepsData'], + ...executionOptions, }) return hasFailures ? 1 : undefined } abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { outputPath = Option.String('--out,-o', { - description: 'Write the result to this path', + description: this.getOutputDescription(), required: true, }) + protected abstract getIntentDefinition(): + | IntentFileCommandDefinition + | IntentNoInputCommandDefinition + protected abstract getIntentRawValues(): Record + + private getOutputDescription(): string { + return this.getIntentDefinition().outputDescription + } } export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentCommandBase { - protected abstract readonly intentDefinition: IntentNoInputCommandDefinition + protected abstract override getIntentDefinition(): IntentNoInputCommandDefinition protected override async run(): Promise { - const step = createSingleStep(this.intentDefinition.execution, this.getIntentRawValues(), false) + const intentDefinition = this.getIntentDefinition() + const step = createSingleStep(intentDefinition.execution, this.getIntentRawValues(), false) const { hasFailures } = await assembliesCommands.create(this.output, this.client, { inputs: [], output: this.outputPath, - outputMode: this.intentDefinition.outputMode, + outputMode: intentDefinition.outputMode, stepsData: { - [this.intentDefinition.execution.resultStepName]: step, + [intentDefinition.execution.resultStepName]: step, } as AssembliesCreateOptions['stepsData'], }) @@ -289,27 +315,19 @@ export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentComma } abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase { - inputs = Option.Array('--input,-i', { - description: 'Provide an input path, directory, URL, or - for stdin', - }) + inputs = inputPathsOption('Provide an input path, directory, URL, or - for stdin') inputBase64 = Option.Array('--input-base64', { description: 'Provide base64-encoded input content directly', }) - recursive = Option.Boolean('--recursive,-r', false, { - description: 'Enumerate input directories recursively', - }) + recursive = recursiveOption() - deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, { - description: 'Delete input files after they are processed', - }) + deleteAfterProcessing = deleteAfterProcessingOption() - reprocessStale = Option.Boolean('--reprocess-stale', false, { - description: 'Process inputs even if output is newer', - }) + reprocessStale = reprocessStaleOption() - protected abstract readonly intentDefinition: IntentFileCommandDefinition + protected abstract override getIntentDefinition(): IntentFileCommandDefinition protected async prepareInputs(): Promise { return await prepareIntentInputs({ @@ -330,28 +348,32 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase } protected getProvidedInputCount(): number { - return (this.inputs ?? []).length + (this.inputBase64 ?? []).length + return countProvidedInputs({ + inputs: this.inputs, + inputBase64: this.inputBase64, + }) } protected validateInputPresence( rawValues: Record, ): number | undefined { + const intentDefinition = this.getIntentDefinition() const inputCount = this.getProvidedInputCount() if (inputCount !== 0) { return undefined } - if (!requiresLocalInput(this.intentDefinition.requiredFieldForInputless, rawValues)) { + if (!requiresLocalInput(intentDefinition.requiredFieldForInputless, rawValues)) { return undefined } - if (this.intentDefinition.requiredFieldForInputless == null) { - this.output.error(`${this.intentDefinition.commandLabel} requires --input or --input-base64`) + if (intentDefinition.requiredFieldForInputless == null) { + this.output.error(`${intentDefinition.commandLabel} requires --input or --input-base64`) return 1 } this.output.error( - `${this.intentDefinition.commandLabel} requires --input or --${this.intentDefinition.requiredFieldForInputless.replaceAll('_', '-')}`, + `${intentDefinition.commandLabel} requires --input or --${intentDefinition.requiredFieldForInputless.replaceAll('_', '-')}`, ) return 1 } @@ -373,7 +395,7 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase return await executeFileIntentCommand({ client: this.client, createOptions: this.getCreateOptions(preparedInputs.inputs), - definition: this.intentDefinition, + definition: this.getIntentDefinition(), output: this.output, outputPath: this.outputPath, rawValues, @@ -402,18 +424,11 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase } export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIntentCommandBase { - watch = Option.Boolean('--watch,-w', false, { - description: 'Watch inputs for changes', - }) + watch = watchOption() - singleAssembly = Option.Boolean('--single-assembly', false, { - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) + singleAssembly = singleAssemblyOption() - concurrency = Option.String('--concurrency,-c', { - description: 'Maximum number of concurrent assemblies (default: 5)', - validator: t.isNumber(), - }) + concurrency = concurrencyOption() protected override getCreateOptions( inputs: string[], @@ -434,17 +449,17 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn return validationError } - if (this.watch && this.getProvidedInputCount() === 0) { - this.output.error( - `${this.intentDefinition.commandLabel} --watch requires --input or --input-base64`, - ) + const sharedValidationError = validateSharedFileProcessingOptions({ + explicitInputCount: this.getProvidedInputCount(), + singleAssembly: this.singleAssembly, + watch: this.watch, + watchRequiresInputsMessage: `${this.getIntentDefinition().commandLabel} --watch requires --input or --input-base64`, + }) + if (sharedValidationError != null) { + this.output.error(sharedValidationError) return 1 } - if (this.singleAssembly && this.watch) { - this.output.error('--single-assembly cannot be used with --watch') - return 1 - } return undefined } diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 56ff1b9e..56c3251b 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -1,6 +1,9 @@ +import { EventEmitter } from 'node:events' import { mkdir, mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import tty from 'node:tty' import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -38,6 +41,7 @@ async function collectRelativeFiles(rootDir: string, currentDir = rootDir): Prom afterEach(async () => { vi.restoreAllMocks() + vi.resetModules() nock.cleanAll() nock.abortPendingRequests() @@ -47,6 +51,134 @@ afterEach(async () => { }) describe('assemblies create', () => { + it('writes result bytes to stdout when output is -', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const output = new OutputCtl() + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-stdout' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + generated: [{ url: 'http://downloads.test/stdout.txt', name: 'stdout.txt' }], + }, + }), + } + + nock('http://downloads.test').get('/stdout.txt').reply(200, 'stdout-contents') + + await expect( + create(output, client as never, { + inputs: [], + output: '-', + stepsData: { + generated: { + robot: '/image/generate', + result: true, + prompt: 'hello', + model: 'flux-schnell', + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + expect(stdoutWrite).toHaveBeenCalled() + expect(stdoutWrite.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain( + 'stdout-contents', + ) + }) + + it('waits for stdout drain before finishing stdout downloads', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const output = new OutputCtl() + let resolved = false + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => false) + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-stdout-drain' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + generated: [{ url: 'http://downloads.test/stdout-drain.txt', name: 'stdout-drain.txt' }], + }, + }), + } + + nock('http://downloads.test').get('/stdout-drain.txt').reply(200, 'stdout-drain') + + const createPromise = create(output, client as never, { + inputs: [], + output: '-', + stepsData: { + generated: { + robot: '/image/generate', + result: true, + prompt: 'hello', + model: 'flux-schnell', + }, + }, + }).then(() => { + resolved = true + }) + + await delay(20) + expect(resolved).toBe(false) + expect(stdoutWrite).toHaveBeenCalled() + + process.stdout.emit('drain') + + await createPromise + expect(resolved).toBe(true) + }) + + it('rejects stdout output when an assembly returns multiple files', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-stdout-multi' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + generated: [ + { url: 'http://downloads.test/stdout-a.txt', name: 'a.txt' }, + { url: 'http://downloads.test/stdout-b.txt', name: 'b.txt' }, + ], + }, + }), + } + + nock('http://downloads.test').get('/stdout-a.txt').reply(200, 'stdout-a') + nock('http://downloads.test').get('/stdout-b.txt').reply(200, 'stdout-b') + + await expect( + create(output, client as never, { + inputs: [], + output: '-', + stepsData: { + generated: { + robot: '/image/generate', + result: true, + prompt: 'hello', + model: 'flux-schnell', + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: true, + }), + ) + + expect(stdoutWrite).not.toHaveBeenCalled() + }) + it('supports bundled single-assembly outputs written to a file path', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -151,6 +283,231 @@ describe('assemblies create', () => { expect(Object.keys(uploads ?? {}).sort()).toEqual(['a.txt', 'b.txt']) }) + it('rewrites existing bundled outputs on single-assembly reruns', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-bundle-rerun-') + const inputA = path.join(tempDir, 'a.txt') + const inputB = path.join(tempDir, 'b.txt') + const outputPath = path.join(tempDir, 'bundle.zip') + + await writeFile(inputA, 'a') + await writeFile(inputB, 'b') + await writeFile(outputPath, 'old-bundle') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-rerun-bundle' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + compressed: [{ url: 'http://downloads.test/bundle-rerun.zip', name: 'bundle.zip' }], + }, + }), + } + + nock('http://downloads.test').get('/bundle-rerun.zip').reply(200, 'fresh-bundle') + + await expect( + create(output, client as never, { + inputs: [inputA, inputB], + output: outputPath, + singleAssembly: true, + stepsData: { + compressed: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + expect(await readFile(outputPath, 'utf8')).toBe('fresh-bundle') + }) + + it('does not let older watch assemblies overwrite newer results', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.resetModules() + + class FakeWatcher extends EventEmitter { + close(): void { + this.emit('close') + } + } + + const fakeWatcher = new FakeWatcher() + vi.doMock('node-watch', () => { + return { + default: vi.fn(() => fakeWatcher), + } + }) + + const { create: createWithWatch } = await import('../../../src/cli/commands/assemblies.ts') + + const tempDir = await createTempDir('transloadit-watch-') + const inputPath = path.join(tempDir, 'clip.mp4') + const outputPath = path.join(tempDir, 'thumb.jpg') + + await writeFile(inputPath, 'video-v1') + await writeFile(outputPath, 'existing-thumb') + + const baseTime = new Date('2026-01-01T00:00:00.000Z') + const outputTime = new Date('2026-01-01T00:00:10.000Z') + const firstChangeTime = new Date('2026-01-01T00:00:20.000Z') + const secondChangeTime = new Date('2026-01-01T00:00:30.000Z') + + await utimes(inputPath, baseTime, baseTime) + await utimes(outputPath, outputTime, outputTime) + + const output = new OutputCtl() + const client = { + createAssembly: vi + .fn() + .mockResolvedValueOnce({ assembly_id: 'assembly-old' }) + .mockResolvedValueOnce({ assembly_id: 'assembly-new' }), + awaitAssemblyCompletion: vi.fn(async (assemblyId: string) => { + if (assemblyId === 'assembly-old') { + await delay(80) + return { + ok: 'ASSEMBLY_COMPLETED', + results: { + thumbs: [{ url: 'http://downloads.test/old.jpg', name: 'old.jpg' }], + }, + } + } + + await delay(10) + return { + ok: 'ASSEMBLY_COMPLETED', + results: { + thumbs: [{ url: 'http://downloads.test/new.jpg', name: 'new.jpg' }], + }, + } + }), + } + + nock('http://downloads.test').get('/old.jpg').reply(200, 'old-result') + nock('http://downloads.test').get('/new.jpg').reply(200, 'new-result') + + const createPromise = createWithWatch(output, client as never, { + inputs: [inputPath], + output: outputPath, + watch: true, + concurrency: 2, + stepsData: { + thumbs: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + }, + }) + + await delay(20) + await writeFile(inputPath, 'video-v2') + await utimes(inputPath, firstChangeTime, firstChangeTime) + fakeWatcher.emit('change', 'update', inputPath) + + await delay(5) + await writeFile(inputPath, 'video-v3') + await utimes(inputPath, secondChangeTime, secondChangeTime) + fakeWatcher.emit('change', 'update', inputPath) + + await delay(20) + fakeWatcher.close() + + await expect(createPromise).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + expect(await readFile(outputPath, 'utf8')).toBe('new-result') + }) + + it('does not try to delete /dev/stdin after stdin processing', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(tty, 'isatty').mockReturnValue(false) + + const tempDir = await createTempDir('transloadit-stdin-') + const outputPath = path.join(tempDir, 'waveform.png') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-stdin' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + waveform: [{ url: 'http://downloads.test/stdin-waveform.png', name: 'waveform.png' }], + }, + }), + } + + nock('http://downloads.test').get('/stdin-waveform.png').reply(200, 'waveform') + + await expect( + create(output, client as never, { + inputs: ['-'], + output: outputPath, + del: true, + stepsData: { + waveform: { + robot: '/audio/waveform', + result: true, + use: ':original', + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + expect(await readFile(outputPath, 'utf8')).toBe('waveform') + }) + + it('surfaces output plan failures through the normal error path', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + const tempDir = await createTempDir('transloadit-output-plan-failure-') + const outputDir = path.join(tempDir, 'out') + await mkdir(outputDir, { recursive: true }) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn(), + awaitAssemblyCompletion: vi.fn(), + } + + await expect( + create(output, client as never, { + inputs: ['-'], + output: outputDir, + outputMode: 'directory', + stepsData: { + waveform: { + robot: '/audio/waveform', + result: true, + use: ':original', + }, + }, + }), + ).rejects.toThrow('You must provide an input to output to a directory') + + expect(client.createAssembly).not.toHaveBeenCalled() + }) + it('writes single-input directory outputs using result filenames', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -205,6 +562,56 @@ describe('assemblies create', () => { expect(await readFile(path.join(outputDir, 'two.jpg'), 'utf8')).toBe('two') }) + it('keeps duplicate sanitized result filenames from overwriting each other', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-dupe-results-') + const inputPath = path.join(tempDir, 'clip.mp4') + const outputDir = path.join(tempDir, 'thumbs') + + await writeFile(inputPath, 'video') + await mkdir(outputDir, { recursive: true }) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-dupe-results' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + thumbs: [ + { url: 'http://downloads.test/dupe-a.jpg', name: 'thumb.jpg' }, + { url: 'http://downloads.test/dupe-b.jpg', name: 'thumb.jpg' }, + ], + }, + }), + } + + nock('http://downloads.test').get('/dupe-a.jpg').reply(200, 'first-thumb') + nock('http://downloads.test').get('/dupe-b.jpg').reply(200, 'second-thumb') + + await expect( + create(output, client as never, { + inputs: [inputPath], + output: outputDir, + outputMode: 'directory', + stepsData: { + thumbs: { + robot: '/video/thumbs', + result: true, + use: ':original', + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + }), + ) + + expect(await readFile(path.join(outputDir, 'thumb.jpg'), 'utf8')).toBe('first-thumb') + expect(await readFile(path.join(outputDir, 'thumb__1.jpg'), 'utf8')).toBe('second-thumb') + }) + it('preserves legacy step-directory layout for generic directory outputs', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index df49c2d6..7cc0f36e 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -9,6 +9,8 @@ import { getIntentResultStepName, intentCatalog, } from '../../../src/cli/intentCommandSpecs.ts' +import { coerceIntentFieldValue } from '../../../src/cli/intentFields.ts' +import { prepareIntentInputs } from '../../../src/cli/intentRuntime.ts' import { intentSmokeCases } from '../../../src/cli/intentSmokeCases.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' @@ -182,6 +184,15 @@ describe('intent commands', () => { ) }) + it('rejects private-host URL inputs for intent commands', async () => { + await expect( + prepareIntentInputs({ + inputValues: ['http://127.0.0.1/secret'], + inputBase64Values: [], + }), + ).rejects.toThrow('URL downloads are limited to public hosts') + }) + it('supports base64 inputs for intent commands', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -501,6 +512,164 @@ describe('intent commands', () => { ) }) + it('maps array-valued robot parameters from JSON flags', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'video', + 'thumbs', + '--input', + 'demo.mp4', + '--offsets', + '[1,2,3]', + '--out', + 'thumbs', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + [getIntentStepName(['video', 'thumbs'])]: expect.objectContaining({ + robot: '/video/thumbs', + offsets: [1, 2, 3], + }), + }, + }), + ) + }) + + it('maps object-valued robot parameters from JSON flags', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'preview', + 'generate', + '--input', + 'document.pdf', + '--strategy', + '{"document":["page","icon"],"unknown":["icon"]}', + '--out', + 'preview.png', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + [getIntentStepName(['preview', 'generate'])]: expect.objectContaining({ + robot: '/file/preview', + strategy: expect.objectContaining({ + document: ['page', 'icon'], + unknown: ['icon'], + }), + }), + }, + }), + ) + }) + + it('rejects blank numeric values instead of coercing them to zero', () => { + expect(() => coerceIntentFieldValue('number', ' ')).toThrow('Expected a number') + }) + + it('parses JSON objects for auto-typed flags like image resize --crop', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'resize', + '--input', + 'demo.jpg', + '--crop', + '{"x1":80,"y1":100,"x2":"60%","y2":"80%"}', + '--out', + 'resized.jpg', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + [getIntentStepName(['image', 'resize'])]: expect.objectContaining({ + crop: { + x1: 80, + y1: 100, + x2: '60%', + y2: '80%', + }, + }), + }, + }), + ) + }) + + it('parses JSON arrays for auto-typed flags like image resize --watermark-position', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'resize', + '--input', + 'demo.jpg', + '--watermark-position', + '["center","left"]', + '--out', + 'resized.jpg', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + stepsData: { + [getIntentStepName(['image', 'resize'])]: expect.objectContaining({ + watermark_position: ['center', 'left'], + }), + }, + }), + ) + }) + it('coerces mixed rotation flags like image resize --rotation 90', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') diff --git a/scripts/prepare-transloadit.ts b/scripts/prepare-transloadit.ts index 09942ceb..418de1f4 100644 --- a/scripts/prepare-transloadit.ts +++ b/scripts/prepare-transloadit.ts @@ -28,36 +28,65 @@ const formatPackageJson = (data: Record): string => { type PackageJson = Record & { scripts?: Record } -const writeLegacyPackageJson = async (): Promise => { - const nodePackageJson = await readJson(resolve(nodePackage, 'package.json')) - const legacyExisting = await readJson(resolve(legacyPackage, 'package.json')).catch( - () => null, - ) - const scripts = { ...(nodePackageJson.scripts ?? {}) } +function replaceRequired( + value: string, + searchValue: string, + replaceValue: string, + label: string, +): string { + if (!value.includes(searchValue)) { + throw new Error(`Expected ${label} to include ${JSON.stringify(searchValue)}`) + } + + return value.replace(searchValue, replaceValue) +} + +function deriveLegacyScripts(nodeScripts: Record): Record { + const scripts = { ...nodeScripts } delete scripts['sync:intents'] + if (scripts.check != null) { - scripts.check = scripts.check.replace('yarn sync:intents && ', '') - scripts.check = scripts.check.replace(' && yarn fix', '') + scripts.check = replaceRequired(scripts.check, 'yarn sync:intents && ', '', 'scripts.check') + scripts.check = replaceRequired(scripts.check, ' && yarn fix', '', 'scripts.check') } + if (scripts['test:unit'] != null) { - scripts['test:unit'] = scripts['test:unit'].replace( + scripts['test:unit'] = replaceRequired( + scripts['test:unit'], 'vitest run --coverage ./test/unit', 'vitest run --coverage --passWithNoTests ./test/unit', + 'scripts.test:unit', ) } + if (scripts['test:e2e'] != null) { - scripts['test:e2e'] = scripts['test:e2e'].replace( + scripts['test:e2e'] = replaceRequired( + scripts['test:e2e'], 'vitest run ./test/e2e', 'vitest run --passWithNoTests ./test/e2e', + 'scripts.test:e2e', ) } + if (scripts.test != null) { - scripts.test = scripts.test.replace( + scripts.test = replaceRequired( + scripts.test, 'vitest run --coverage', 'vitest run --coverage --passWithNoTests', + 'scripts.test', ) } + scripts.prepack = 'node ../../scripts/prepare-transloadit.ts' + return scripts +} + +const writeLegacyPackageJson = async (): Promise => { + const nodePackageJson = await readJson(resolve(nodePackage, 'package.json')) + const legacyExisting = await readJson(resolve(legacyPackage, 'package.json')).catch( + () => null, + ) + const scripts = deriveLegacyScripts(nodePackageJson.scripts ?? {}) const legacyPackageJson: PackageJson = { ...nodePackageJson, name: 'transloadit', From 72e1024e3ee73d76504d6283f26c3607fc454012 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 22:42:05 +0200 Subject: [PATCH 21/44] refactor(node): tighten intent inference flow --- .../node/scripts/generate-intent-commands.ts | 17 +++--- packages/node/src/cli/commands/assemblies.ts | 59 +++++++------------ .../src/cli/commands/generated-intents.ts | 45 +++++++++++++- packages/node/src/cli/intentCommandSpecs.ts | 30 ---------- packages/node/src/cli/intentInputPolicy.ts | 11 ++++ .../node/src/cli/intentResolvedDefinitions.ts | 49 +++++++++------ packages/node/src/cli/intentRuntime.ts | 34 +++++++---- 7 files changed, 137 insertions(+), 108 deletions(-) create mode 100644 packages/node/src/cli/intentInputPolicy.ts diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index c7c3a8f2..a5c3aeb1 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -90,29 +90,25 @@ function getBaseClassName(spec: ResolvedIntentCommandSpec): string { function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { if (spec.execution.kind === 'single-step') { - const attachUseWhenInputsProvided = - spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null - ? '\n attachUseWhenInputsProvided: true,' - : '' const commandLabelLine = spec.input.kind === 'local-files' ? `\n commandLabel: ${JSON.stringify(spec.commandLabel)},` : '' - const requiredField = - spec.input.kind === 'local-files' && spec.input.requiredFieldForInputless != null - ? `\n requiredFieldForInputless: ${JSON.stringify(spec.input.requiredFieldForInputless)},` + const inputPolicyLine = + spec.input.kind === 'local-files' + ? `\n inputPolicy: ${JSON.stringify(spec.input.inputPolicy, null, 4).replace(/\n/g, '\n ')},` : '' const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` const outputLines = `\n outputDescription: ${JSON.stringify(spec.outputDescription)},\n outputRequired: ${JSON.stringify(spec.outputRequired)},` - return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${requiredField}${outputMode}${outputLines} + return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${inputPolicyLine}${outputMode}${outputLines} execution: { kind: 'single-step', schema: ${spec.schemaSpec?.importName}, fieldSpecs: ${formatFieldSpecsLiteral(spec.fieldSpecs)}, fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 4).replace(/\n/g, '\n ')}, - resultStepName: ${JSON.stringify(spec.execution.resultStepName)},${attachUseWhenInputsProvided} + resultStepName: ${JSON.stringify(spec.execution.resultStepName)}, }, } as const` } @@ -120,7 +116,8 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` return `const ${getCommandDefinitionName(spec)} = { - commandLabel: ${JSON.stringify(spec.commandLabel)},${outputMode} + commandLabel: ${JSON.stringify(spec.commandLabel)}, + inputPolicy: { "kind": "required" },${outputMode} outputDescription: ${JSON.stringify(spec.outputDescription)}, outputRequired: ${JSON.stringify(spec.outputRequired)}, execution: { diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 2f9a479f..8494b813 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -8,7 +8,6 @@ import type { Readable } from 'node:stream' import { Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { setTimeout as delay } from 'node:timers/promises' -import tty from 'node:tty' import { promisify } from 'node:util' import { Command, Option } from 'clipanion' import got from 'got' @@ -316,7 +315,7 @@ interface OutputPlan { } interface Job { - in: Readable | null + inputPath: string | null out: OutputPlan | null } @@ -366,18 +365,17 @@ async function myStat( return await fsp.stat(filepath) } -function createInputJobStream(filepath: string): Readable | null { +function getJobInputPath(filepath: string): string { const normalizedFile = path.normalize(filepath) - if (normalizedFile === '-') { - if (tty.isatty(process.stdin.fd)) { - return null - } - - return process.stdin + return stdinWithPath.path } - const instream = fs.createReadStream(normalizedFile) + return normalizedFile +} + +function createInputUploadStream(filepath: string): Readable { + const instream = fs.createReadStream(filepath) // Attach a no-op error handler to prevent unhandled errors if stream is destroyed // before being consumed (e.g., due to output collision detection) instream.on('error', () => {}) @@ -820,8 +818,7 @@ class ReaddirJobEmitter extends MyEventEmitter { } } else { const outputPlan = await outputPlanProvider(file, topdir) - const instream = createInputJobStream(file) - this.emit('job', { in: instream, out: outputPlan }) + this.emit('job', { inputPath: getJobInputPath(file), out: outputPlan }) } } } @@ -833,10 +830,8 @@ class SingleJobEmitter extends MyEventEmitter { const normalizedFile = path.normalize(file) outputPlanProvider(normalizedFile) .then((outputPlan) => { - const instream = createInputJobStream(normalizedFile) - process.nextTick(() => { - this.emit('job', { in: instream, out: outputPlan }) + this.emit('job', { inputPath: getJobInputPath(normalizedFile), out: outputPlan }) this.emit('end') }) }) @@ -856,7 +851,7 @@ class InputlessJobEmitter extends MyEventEmitter { outputPlanProvider(null) .then((outputPlan) => { try { - this.emit('job', { in: null, out: outputPlan }) + this.emit('job', { inputPath: null, out: outputPlan }) } catch (err) { this.emit('error', ensureError(err)) return @@ -935,8 +930,7 @@ class WatchJobEmitter extends MyEventEmitter { if (stats.isDirectory()) return const outputPlan = await outputPlanProvider(normalizedFile, topdir) - const instream = createInputJobStream(normalizedFile) - this.emit('job', { in: instream, out: outputPlan }) + this.emit('job', { inputPath: getJobInputPath(normalizedFile), out: outputPlan }) } } @@ -994,11 +988,11 @@ function detectConflicts(jobEmitter: EventEmitter): MyEventEmitter { jobEmitter.on('end', () => emitter.emit('end')) jobEmitter.on('error', (err: Error) => emitter.emit('error', err)) jobEmitter.on('job', (job: Job) => { - if (job.in == null || job.out == null) { + if (job.inputPath == null || job.out == null) { emitter.emit('job', job) return } - const inPath = (job.in as fs.ReadStream).path as string + const inPath = job.inputPath const outPath = job.out.path if (outPath == null) { emitter.emit('job', job) @@ -1025,12 +1019,12 @@ function dismissStaleJobs(jobEmitter: EventEmitter): MyEventEmitter { jobEmitter.on('end', () => Promise.all(pendingChecks).then(() => emitter.emit('end'))) jobEmitter.on('error', (err: Error) => emitter.emit('error', err)) jobEmitter.on('job', (job: Job) => { - if (job.in == null || job.out == null) { + if (job.inputPath == null || job.out == null) { emitter.emit('job', job) return } - const inPath = (job.in as fs.ReadStream).path as string + const inPath = job.inputPath const checkPromise = fsp .stat(inPath) .then((stats) => { @@ -1379,7 +1373,7 @@ export async function create( inPath: string | null, outputPlan: OutputPlan | null, ): Promise { - const inStream = inPath ? fs.createReadStream(inPath) : null + const inStream = inPath ? createInputUploadStream(inPath) : null inStream?.on('error', () => {}) return await executeAssemblyLifecycle({ @@ -1392,17 +1386,13 @@ export async function create( if (singleAssembly) { // Single-assembly mode: collect file paths, then create one assembly with all inputs - // We close streams immediately to avoid exhausting file descriptors with many files const collectedPaths: string[] = [] emitter.on('job', (job: Job) => { - if (job.in != null) { - const inPath = (job.in as fs.ReadStream).path as string + if (job.inputPath != null) { + const inPath = job.inputPath outputctl.debug(`COLLECTING JOB ${inPath}`) collectedPaths.push(inPath) - // Close the stream immediately to avoid file descriptor exhaustion - ;(job.in as fs.ReadStream).destroy() - outputctl.debug(`STREAM CLOSED ${inPath}`) } }) @@ -1430,7 +1420,7 @@ export async function create( key = `${path.parse(basename).name}_${counter}${path.parse(basename).ext}` counter++ } - uploads[key] = fs.createReadStream(inPath) + uploads[key] = createInputUploadStream(inPath) inputPaths.push(inPath) } @@ -1458,16 +1448,9 @@ export async function create( } else { // Default mode: one assembly per file with p-queue concurrency limiting emitter.on('job', (job: Job) => { - const inPath = job.in - ? (((job.in as fs.ReadStream).path as string | undefined) ?? null) - : null + const inPath = job.inputPath const outputPlan = job.out outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) - - // Close the original streams immediately - we'll create fresh ones when processing - if (job.in != null) { - ;(job.in as fs.ReadStream).destroy() - } // Add job to queue - p-queue handles concurrency automatically queue .add(async () => { diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index a440e9f2..5a525d8e 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -44,6 +44,7 @@ const imageGenerateCommandDefinition = { fixedValues: { robot: '/image/generate', result: true, + use: ':original', }, resultStepName: 'generate', }, @@ -51,6 +52,9 @@ const imageGenerateCommandDefinition = { const previewGenerateCommandDefinition = { commandLabel: 'preview generate', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -94,6 +98,9 @@ const previewGenerateCommandDefinition = { const imageRemoveBackgroundCommandDefinition = { commandLabel: 'image remove-background', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -117,6 +124,9 @@ const imageRemoveBackgroundCommandDefinition = { const imageOptimizeCommandDefinition = { commandLabel: 'image optimize', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -140,6 +150,9 @@ const imageOptimizeCommandDefinition = { const imageResizeCommandDefinition = { commandLabel: 'image resize', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -204,6 +217,9 @@ const imageResizeCommandDefinition = { const documentConvertCommandDefinition = { commandLabel: 'document convert', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -232,6 +248,9 @@ const documentConvertCommandDefinition = { const documentOptimizeCommandDefinition = { commandLabel: 'document optimize', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -258,6 +277,9 @@ const documentOptimizeCommandDefinition = { const documentAutoRotateCommandDefinition = { commandLabel: 'document auto-rotate', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -276,6 +298,9 @@ const documentAutoRotateCommandDefinition = { const documentThumbsCommandDefinition = { commandLabel: 'document thumbs', + inputPolicy: { + kind: 'required', + }, outputMode: 'directory', outputDescription: 'Write the results to this directory', outputRequired: true, @@ -309,6 +334,9 @@ const documentThumbsCommandDefinition = { const audioWaveformCommandDefinition = { commandLabel: 'audio waveform', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -354,7 +382,11 @@ const audioWaveformCommandDefinition = { const textSpeakCommandDefinition = { commandLabel: 'text speak', - requiredFieldForInputless: 'prompt', + inputPolicy: { + kind: 'optional', + field: 'prompt', + attachUseWhenInputsProvided: true, + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -373,12 +405,14 @@ const textSpeakCommandDefinition = { result: true, }, resultStepName: 'speak', - attachUseWhenInputsProvided: true, }, } as const const videoThumbsCommandDefinition = { commandLabel: 'video thumbs', + inputPolicy: { + kind: 'required', + }, outputMode: 'directory', outputDescription: 'Write the results to this directory', outputRequired: true, @@ -408,6 +442,7 @@ const videoThumbsCommandDefinition = { const videoEncodeHlsCommandDefinition = { commandLabel: 'video encode-hls', + inputPolicy: { kind: 'required' }, outputMode: 'directory', outputDescription: 'Write the results to this directory', outputRequired: true, @@ -419,6 +454,9 @@ const videoEncodeHlsCommandDefinition = { const fileCompressCommandDefinition = { commandLabel: 'file compress', + inputPolicy: { + kind: 'required', + }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', outputRequired: true, @@ -447,6 +485,9 @@ const fileCompressCommandDefinition = { const fileDecompressCommandDefinition = { commandLabel: 'file decompress', + inputPolicy: { + kind: 'required', + }, outputMode: 'directory', outputDescription: 'Write the results to this directory', outputRequired: true, diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index d8b5c755..b9908b32 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -64,8 +64,6 @@ export type IntentOutputMode = 'directory' | 'file' interface IntentSchemaDefinition { meta: RobotMetaInput schema: z.AnyZodObject - schemaImportName: string - schemaImportPath: string } interface IntentBaseDefinition { @@ -163,8 +161,6 @@ export const intentCatalog = [ robot: '/image/generate', meta: robotImageGenerateMeta, schema: robotImageGenerateInstructionsSchema, - schemaImportName: 'robotImageGenerateInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/image-generate.ts', }), defineRobotIntent({ kind: 'robot', @@ -172,56 +168,42 @@ export const intentCatalog = [ paths: ['preview', 'generate'], meta: robotFilePreviewMeta, schema: robotFilePreviewInstructionsSchema, - schemaImportName: 'robotFilePreviewInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/file-preview.ts', }), defineRobotIntent({ kind: 'robot', robot: '/image/bgremove', meta: robotImageBgremoveMeta, schema: robotImageBgremoveInstructionsSchema, - schemaImportName: 'robotImageBgremoveInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/image-bgremove.ts', }), defineRobotIntent({ kind: 'robot', robot: '/image/optimize', meta: robotImageOptimizeMeta, schema: robotImageOptimizeInstructionsSchema, - schemaImportName: 'robotImageOptimizeInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/image-optimize.ts', }), defineRobotIntent({ kind: 'robot', robot: '/image/resize', meta: robotImageResizeMeta, schema: robotImageResizeInstructionsSchema, - schemaImportName: 'robotImageResizeInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/image-resize.ts', }), defineRobotIntent({ kind: 'robot', robot: '/document/convert', meta: robotDocumentConvertMeta, schema: robotDocumentConvertInstructionsSchema, - schemaImportName: 'robotDocumentConvertInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-convert.ts', }), defineRobotIntent({ kind: 'robot', robot: '/document/optimize', meta: robotDocumentOptimizeMeta, schema: robotDocumentOptimizeInstructionsSchema, - schemaImportName: 'robotDocumentOptimizeInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-optimize.ts', }), defineRobotIntent({ kind: 'robot', robot: '/document/autorotate', meta: robotDocumentAutorotateMeta, schema: robotDocumentAutorotateInstructionsSchema, - schemaImportName: 'robotDocumentAutorotateInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-autorotate.ts', }), defineRobotIntent({ kind: 'robot', @@ -229,24 +211,18 @@ export const intentCatalog = [ outputMode: 'directory', meta: robotDocumentThumbsMeta, schema: robotDocumentThumbsInstructionsSchema, - schemaImportName: 'robotDocumentThumbsInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/document-thumbs.ts', }), defineRobotIntent({ kind: 'robot', robot: '/audio/waveform', meta: robotAudioWaveformMeta, schema: robotAudioWaveformInstructionsSchema, - schemaImportName: 'robotAudioWaveformInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/audio-waveform.ts', }), defineRobotIntent({ kind: 'robot', robot: '/text/speak', meta: robotTextSpeakMeta, schema: robotTextSpeakInstructionsSchema, - schemaImportName: 'robotTextSpeakInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/text-speak.ts', }), defineRobotIntent({ kind: 'robot', @@ -254,8 +230,6 @@ export const intentCatalog = [ outputMode: 'directory', meta: robotVideoThumbsMeta, schema: robotVideoThumbsInstructionsSchema, - schemaImportName: 'robotVideoThumbsInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/video-thumbs.ts', }), defineTemplateIntent({ kind: 'template', @@ -269,8 +243,6 @@ export const intentCatalog = [ defaultSingleAssembly: true, meta: robotFileCompressMeta, schema: robotFileCompressInstructionsSchema, - schemaImportName: 'robotFileCompressInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/file-compress.ts', }), defineRobotIntent({ kind: 'robot', @@ -278,7 +250,5 @@ export const intentCatalog = [ outputMode: 'directory', meta: robotFileDecompressMeta, schema: robotFileDecompressInstructionsSchema, - schemaImportName: 'robotFileDecompressInstructionsSchema', - schemaImportPath: '../../alphalib/types/robots/file-decompress.ts', }), ] satisfies IntentDefinition[] diff --git a/packages/node/src/cli/intentInputPolicy.ts b/packages/node/src/cli/intentInputPolicy.ts new file mode 100644 index 00000000..c72dc576 --- /dev/null +++ b/packages/node/src/cli/intentInputPolicy.ts @@ -0,0 +1,11 @@ +export interface RequiredIntentInputPolicy { + kind: 'required' +} + +export interface OptionalIntentInputPolicy { + attachUseWhenInputsProvided: boolean + field: string + kind: 'optional' +} + +export type IntentInputPolicy = OptionalIntentInputPolicy | RequiredIntentInputPolicy diff --git a/packages/node/src/cli/intentResolvedDefinitions.ts b/packages/node/src/cli/intentResolvedDefinitions.ts index 9a094df9..bf9c2e41 100644 --- a/packages/node/src/cli/intentResolvedDefinitions.ts +++ b/packages/node/src/cli/intentResolvedDefinitions.ts @@ -11,6 +11,7 @@ import type { import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intentCommandSpecs.ts' import type { IntentFieldKind, IntentFieldSpec } from './intentFields.ts' import { inferIntentFieldKind } from './intentFields.ts' +import type { IntentInputPolicy } from './intentInputPolicy.ts' export interface GeneratedSchemaField extends IntentFieldSpec { description?: string @@ -26,8 +27,8 @@ export interface ResolvedIntentLocalFilesInput { defaultSingleAssembly?: boolean deleteAfterProcessing?: boolean description: string + inputPolicy: IntentInputPolicy kind: 'local-files' - requiredFieldForInputless?: string recursive?: boolean reprocessStale?: boolean } @@ -145,6 +146,14 @@ function toPascalCase(parts: string[]): string { .join('') } +function getSchemaImportName(robot: string): string { + return `robot${toPascalCase(robot.split('/').filter(Boolean))}InstructionsSchema` +} + +function getSchemaImportPath(robot: string): string { + return `../../alphalib/types/robots/${robot.split('/').filter(Boolean).join('-')}.ts` +} + function stripTrailingPunctuation(value: string): string { return value.replace(/[.:]+$/, '').trim() } @@ -246,11 +255,11 @@ function inferInputModeFromShape(shape: Record): IntentInput function inferInputSpecFromAnalysis({ defaultSingleAssembly, inputMode, - requiredFieldForInputless, + inputPolicy, }: { defaultSingleAssembly?: boolean inputMode: IntentInputMode - requiredFieldForInputless?: string + inputPolicy: IntentInputPolicy }): ResolvedIntentInput { if (inputMode === 'none') { return { kind: 'none' } @@ -264,7 +273,7 @@ function inferInputSpecFromAnalysis({ deleteAfterProcessing: true, reprocessStale: true, defaultSingleAssembly: true, - requiredFieldForInputless, + inputPolicy, } } @@ -277,19 +286,17 @@ function inferInputSpecFromAnalysis({ reprocessStale: true, allowSingleAssembly: true, allowConcurrency: true, - requiredFieldForInputless, + inputPolicy, } } function inferFixedValuesFromAnalysis({ defaultSingleAssembly, - inputMode, - promptIsOptional, + inputPolicy, robot, }: { defaultSingleAssembly?: boolean - inputMode: IntentInputMode - promptIsOptional: boolean + inputPolicy: IntentInputPolicy robot: string }): Record { if (defaultSingleAssembly) { @@ -303,7 +310,7 @@ function inferFixedValuesFromAnalysis({ } } - if (inputMode === 'local-files' && !promptIsOptional) { + if (inputPolicy.kind === 'required') { return { robot, result: true, @@ -353,18 +360,24 @@ function analyzeRobotIntent(definition: RobotIntentDefinition): RobotIntentAnaly const className = `${toPascalCase(paths)}Command` const outputMode = definition.outputMode ?? 'file' const schemaSpec = { - importName: definition.schemaImportName, - importPath: definition.schemaImportPath, + importName: getSchemaImportName(definition.robot), + importPath: getSchemaImportPath(definition.robot), schema: definition.schema as ZodObject, } satisfies ResolvedIntentSchemaSpec const schemaShape = schemaSpec.schema.shape as Record const inputMode = definition.inputMode ?? inferInputModeFromShape(schemaShape) const promptIsOptional = 'prompt' in schemaShape && !unwrapSchema(schemaShape.prompt).required - const requiredFieldForInputless = promptIsOptional ? 'prompt' : undefined + const inputPolicy = promptIsOptional + ? ({ + kind: 'optional', + field: 'prompt', + attachUseWhenInputsProvided: true, + } satisfies IntentInputPolicy) + : ({ kind: 'required' } satisfies IntentInputPolicy) const input = inferInputSpecFromAnalysis({ defaultSingleAssembly: definition.defaultSingleAssembly, inputMode, - requiredFieldForInputless, + inputPolicy, }) const execution = { kind: 'single-step', @@ -375,8 +388,7 @@ function analyzeRobotIntent(definition: RobotIntentDefinition): RobotIntentAnaly })(), fixedValues: inferFixedValuesFromAnalysis({ defaultSingleAssembly: definition.defaultSingleAssembly, - inputMode, - promptIsOptional, + inputPolicy, robot: definition.robot, }), } satisfies ResolvedIntentSingleStepExecution @@ -482,7 +494,10 @@ function resolveTemplateIntentSpec( templateId: definition.templateId, }, fieldSpecs: [], - input: inferInputSpecFromAnalysis({ inputMode: 'local-files' }), + input: inferInputSpecFromAnalysis({ + inputMode: 'local-files', + inputPolicy: { kind: 'required' }, + }), outputDescription: outputMode === 'directory' ? 'Write the results to this directory' diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 3b421b64..ece508fa 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -19,6 +19,7 @@ import { } from './fileProcessingOptions.ts' import type { IntentFieldSpec } from './intentFields.ts' import { coerceIntentFieldValue } from './intentFields.ts' +import type { IntentInputPolicy } from './intentInputPolicy.ts' export interface PreparedIntentInputs { cleanup: Array<() => Promise> @@ -27,7 +28,6 @@ export interface PreparedIntentInputs { } export interface IntentSingleStepExecutionDefinition { - attachUseWhenInputsProvided?: boolean fieldSpecs: readonly IntentFieldSpec[] fixedValues: Record kind: 'single-step' @@ -47,10 +47,10 @@ export type IntentFileExecutionDefinition = export interface IntentFileCommandDefinition { commandLabel: string execution: IntentFileExecutionDefinition + inputPolicy: IntentInputPolicy outputDescription: string outputMode?: 'directory' | 'file' outputRequired: boolean - requiredFieldForInputless?: string } export interface IntentNoInputCommandDefinition { @@ -203,9 +203,14 @@ export function parseIntentStep({ function resolveSingleStepFixedValues( execution: IntentSingleStepExecutionDefinition, + inputPolicy: IntentInputPolicy, hasInputs: boolean, ): Record { - if (!hasInputs || execution.attachUseWhenInputsProvided !== true) { + if (!hasInputs) { + return execution.fixedValues + } + + if (inputPolicy.kind !== 'optional' || inputPolicy.attachUseWhenInputsProvided !== true) { return execution.fixedValues } @@ -217,26 +222,27 @@ function resolveSingleStepFixedValues( function createSingleStep( execution: IntentSingleStepExecutionDefinition, + inputPolicy: IntentInputPolicy, rawValues: Record, hasInputs: boolean, ): z.input { return parseIntentStep({ schema: execution.schema, - fixedValues: resolveSingleStepFixedValues(execution, hasInputs), + fixedValues: resolveSingleStepFixedValues(execution, inputPolicy, hasInputs), fieldSpecs: execution.fieldSpecs, rawValues, }) } function requiresLocalInput( - requiredFieldForInputless: string | undefined, + inputPolicy: IntentInputPolicy, rawValues: Record, ): boolean { - if (requiredFieldForInputless == null) { + if (inputPolicy.kind === 'required') { return true } - return rawValues[requiredFieldForInputless] == null + return rawValues[inputPolicy.field] == null } async function executeFileIntentCommand({ @@ -263,6 +269,7 @@ async function executeFileIntentCommand({ stepsData: { [definition.execution.resultStepName]: createSingleStep( definition.execution, + definition.inputPolicy, rawValues, createOptions.inputs.length > 0, ), @@ -300,7 +307,12 @@ export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentComma protected override async run(): Promise { const intentDefinition = this.getIntentDefinition() - const step = createSingleStep(intentDefinition.execution, this.getIntentRawValues(), false) + const step = createSingleStep( + intentDefinition.execution, + { kind: 'required' }, + this.getIntentRawValues(), + false, + ) const { hasFailures } = await assembliesCommands.create(this.output, this.client, { inputs: [], output: this.outputPath, @@ -363,17 +375,17 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase return undefined } - if (!requiresLocalInput(intentDefinition.requiredFieldForInputless, rawValues)) { + if (!requiresLocalInput(intentDefinition.inputPolicy, rawValues)) { return undefined } - if (intentDefinition.requiredFieldForInputless == null) { + if (intentDefinition.inputPolicy.kind === 'required') { this.output.error(`${intentDefinition.commandLabel} requires --input or --input-base64`) return 1 } this.output.error( - `${intentDefinition.commandLabel} requires --input or --${intentDefinition.requiredFieldForInputless.replaceAll('_', '-')}`, + `${intentDefinition.commandLabel} requires --input or --${intentDefinition.inputPolicy.field.replaceAll('_', '-')}`, ) return 1 } From 5a8a3f14c62e2d49876b1ac11078572b98fee8b9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 22:57:35 +0200 Subject: [PATCH 22/44] chore(parity): normalize fingerprint package paths --- docs/fingerprint/transloadit-after.json | 2 +- docs/fingerprint/transloadit-baseline.json | 195 +++++++++++++----- .../transloadit-baseline.package.json | 9 +- scripts/fingerprint-pack.ts | 9 +- 4 files changed, 161 insertions(+), 54 deletions(-) diff --git a/docs/fingerprint/transloadit-after.json b/docs/fingerprint/transloadit-after.json index cca93ce0..d4e5197d 100644 --- a/docs/fingerprint/transloadit-after.json +++ b/docs/fingerprint/transloadit-after.json @@ -1,5 +1,5 @@ { - "packageDir": "/home/kvz/code/node-sdk/packages/transloadit", + "packageDir": "packages/transloadit", "tarball": { "filename": "transloadit-4.1.2.tgz", "sizeBytes": 1110470, diff --git a/docs/fingerprint/transloadit-baseline.json b/docs/fingerprint/transloadit-baseline.json index c07d25d9..12d7fe1a 100644 --- a/docs/fingerprint/transloadit-baseline.json +++ b/docs/fingerprint/transloadit-baseline.json @@ -1,9 +1,9 @@ { - "packageDir": "/Users/kvz/code/node-sdk/packages/transloadit", + "packageDir": "packages/transloadit", "tarball": { "filename": "transloadit-4.7.5.tgz", - "sizeBytes": 1321366, - "sha256": "4851dea426769890fb4f6afa664c6a4d561d16d64f6cf11dc72e69ef25481028" + "sizeBytes": 1338690, + "sha256": "82b176af124eca81eec440520d3ca4b68525b257b1dd59e3f1a4333e62e26e9e" }, "packageJson": { "name": "transloadit", @@ -13,7 +13,10 @@ ".": "./dist/Transloadit.js", "./package.json": "./package.json" }, - "files": ["dist", "src"] + "files": [ + "dist", + "src" + ] }, "files": [ { @@ -48,8 +51,8 @@ }, { "path": "dist/cli/commands/assemblies.js", - "sizeBytes": 51785, - "sha256": "7c2279e65fe8bcc4221da04185d4f86128dad847b475471f3e6f51a340446123" + "sizeBytes": 50297, + "sha256": "c88802ee5f259357a626addd8e8602c39672bbb1e658515e956db8ad09934fb5" }, { "path": "dist/alphalib/types/assembliesGet.js", @@ -316,6 +319,11 @@ "sizeBytes": 1228, "sha256": "474e8f93000f842761a1cebe9282c17eeba8c809f1d8ef25db026796edacbf89" }, + { + "path": "dist/cli/fileProcessingOptions.js", + "sizeBytes": 1907, + "sha256": "dcc0a2470ca0003901ab4fc24f033f27f3b3e2fe7db131a166b20efe29568b59" + }, { "path": "dist/alphalib/types/robots/ftp-import.js", "sizeBytes": 2406, @@ -328,8 +336,8 @@ }, { "path": "dist/cli/commands/generated-intents.js", - "sizeBytes": 80156, - "sha256": "78b4ef99a8190fc734bc50fd23b1895ab58a7d0899c67d12c58a5de118145615" + "sizeBytes": 86298, + "sha256": "f64a7238d2954d1ff71ab02be0d2f18d1dd048e3543cd9a79950794fdbdcd365" }, { "path": "dist/alphalib/types/robots/google-import.js", @@ -413,13 +421,28 @@ }, { "path": "dist/cli/intentCommandSpecs.js", - "sizeBytes": 8571, - "sha256": "51be45b70ed24ee4503e2650d1e7a0813afea58d8988fbf533277b7fd13116df" + "sizeBytes": 6595, + "sha256": "19fc06131e457c60d77d46fcfbf970855849b08b16f76ee76fa65f2188dc9c4c" + }, + { + "path": "dist/cli/intentFields.js", + "sizeBytes": 3431, + "sha256": "dd72c1bbbb64be5b3f346803935060707b203d364060d7fc10a44b063eb6110c" + }, + { + "path": "dist/cli/intentInputPolicy.js", + "sizeBytes": 56, + "sha256": "f2dfdc05ddec25bf8ae63448d8e562ff7ba6ec3b17b4ea4be0adb151017c5991" + }, + { + "path": "dist/cli/intentResolvedDefinitions.js", + "sizeBytes": 12204, + "sha256": "1caadb7700937def4eb86539f8e8f12f4bc532f9ab944368ffd2ffcef527ce6b" }, { "path": "dist/cli/intentRuntime.js", - "sizeBytes": 11592, - "sha256": "67306e344a413251a4f3be40fcc59c9f9cfd2a3a7fc39f1613ae12e75f9033d4" + "sizeBytes": 10990, + "sha256": "e2e2ef1c92038c176922a69db8c1637fb97228a4cbb08057d27b31a33121a074" }, { "path": "dist/cli/intentSmokeCases.js", @@ -718,8 +741,8 @@ }, { "path": "package.json", - "sizeBytes": 2777, - "sha256": "a0d72a6f0de8270f450f8ae25ec279b7b933735063940df62f90eb09711688a0" + "sizeBytes": 2734, + "sha256": "154923aac42eb65b220c74a778fddb5c74eef07d0024fbd325100f82993ce6b2" }, { "path": "dist/alphalib/types/robots/_index.d.ts.map", @@ -773,13 +796,13 @@ }, { "path": "dist/cli/commands/assemblies.d.ts.map", - "sizeBytes": 3877, - "sha256": "34168a6c15c65795f807f296f3245b7b57ea869d6a450a87f1c81462ee0f81b5" + "sizeBytes": 3889, + "sha256": "fdb9b7ad5f7d7ceae62c5bc690c823697b0316a460de39fa5243d5b89dcd6fb4" }, { "path": "dist/cli/commands/assemblies.js.map", - "sizeBytes": 46773, - "sha256": "f2acb3a132a46d27f42be7e63a77fb3749833a90d3bbce5d4c64c13965363893" + "sizeBytes": 46414, + "sha256": "7e3bc37a39d0d3a320a10a8a0dc65169cf10064e9e744700a1837bb22ccdb1f4" }, { "path": "dist/alphalib/types/assembliesGet.d.ts.map", @@ -1311,6 +1334,16 @@ "sizeBytes": 1017, "sha256": "6583f0e6b3a04b39758bc60bbd77383f00715365ac714be95b871ba6797050b9" }, + { + "path": "dist/cli/fileProcessingOptions.d.ts.map", + "sizeBytes": 911, + "sha256": "c2a4f82001dc780feba5894d66f72f1977d23e4ace574c0eda2c751946d11827" + }, + { + "path": "dist/cli/fileProcessingOptions.js.map", + "sizeBytes": 1588, + "sha256": "3635f9b2407ba7bb4a82884b7c284aa651a54f3ba4b2b2df3cfb450ce179c76c" + }, { "path": "dist/alphalib/types/robots/ftp-import.d.ts.map", "sizeBytes": 976, @@ -1333,13 +1366,13 @@ }, { "path": "dist/cli/commands/generated-intents.d.ts.map", - "sizeBytes": 9477, - "sha256": "8a092bbeec0210a9a95e857e59adc4885d1f5db3898a35f2963b704f1f1c3303" + "sizeBytes": 9296, + "sha256": "3ce6b15ecd331d084554df1d694418252ca852fe7a3dba793a6c14a11db917f5" }, { "path": "dist/cli/commands/generated-intents.js.map", - "sizeBytes": 38113, - "sha256": "9aafe6bd60cc360d4dd6a05adeda0c1e97ae89a9e0661f3ffc065d2ab4f1de0a" + "sizeBytes": 39425, + "sha256": "bcbe46850689d5ac0ee546d7904623f41ddfe312f3bc4ad6ae71f39d69c23067" }, { "path": "dist/alphalib/types/robots/google-import.d.ts.map", @@ -1503,23 +1536,53 @@ }, { "path": "dist/cli/intentCommandSpecs.d.ts.map", - "sizeBytes": 1251, - "sha256": "aa4ca0d044e7fa4da9511e5741c0326e4f48b73e0fa177b22700b0ea1dc280cd" + "sizeBytes": 1195, + "sha256": "1621122696872464f8e01e51236beb7cccf3479149b3257141d16346f585622e" }, { "path": "dist/cli/intentCommandSpecs.js.map", - "sizeBytes": 5562, - "sha256": "0796aa6c0980187fd622040be588d299a3c51671bf45c1cf36bda74b8097bb7c" + "sizeBytes": 4862, + "sha256": "2e8c6e2ad7ca01caaad39a3404aa5bf0062569e37d30d27de5a473416127bbb4" + }, + { + "path": "dist/cli/intentFields.d.ts.map", + "sizeBytes": 492, + "sha256": "64be986a13e9b21e1c7bc047c01df4d06105eba0cb14da660791dd26a07b2090" + }, + { + "path": "dist/cli/intentFields.js.map", + "sizeBytes": 3606, + "sha256": "812807a35eb785d9415db2b134f766b25130f63a143955b07348ca52dbb608de" + }, + { + "path": "dist/cli/intentInputPolicy.d.ts.map", + "sizeBytes": 346, + "sha256": "a4d49f03eba0c6811f065f0048f3f3efa454f32eee70050ce598e180d50827db" + }, + { + "path": "dist/cli/intentInputPolicy.js.map", + "sizeBytes": 133, + "sha256": "3f85c00a0565c65820326f2e6c694648153782cce52bb6b806dd4a68896669b1" + }, + { + "path": "dist/cli/intentResolvedDefinitions.d.ts.map", + "sizeBytes": 1873, + "sha256": "e7c166c13d834a2f5b2316dbbe26f24bc17247314b15223255e435face5631ce" + }, + { + "path": "dist/cli/intentResolvedDefinitions.js.map", + "sizeBytes": 10243, + "sha256": "6005cb3491d92ff86da8d52b49cb9c1aad07f8e395bcdf4b02aee974a8bbbfdb" }, { "path": "dist/cli/intentRuntime.d.ts.map", - "sizeBytes": 2925, - "sha256": "5009bb93c79fc17697ac4a4ea16a716fef038f5e508b5a7551108abe3758c35f" + "sizeBytes": 3272, + "sha256": "54e3e9404ce47f1450005a50b49a185469b5bb8f1afb1367c340d4b1b73f5951" }, { "path": "dist/cli/intentRuntime.js.map", - "sizeBytes": 10400, - "sha256": "04832c4b21892b55b0a53cddada09e2c543bdfe6b7c45bb31fef87b166b4d138" + "sizeBytes": 9520, + "sha256": "804c1df6a3285d08b606b02b392271f4b64eded2dae29659eeef587d477c2ef2" }, { "path": "dist/cli/intentSmokeCases.d.ts.map", @@ -2168,13 +2231,13 @@ }, { "path": "dist/cli/commands/assemblies.d.ts", - "sizeBytes": 4500, - "sha256": "2ae7e9403ca1045ae511aa4d6b2b6082582bc9ef89d1f5887c6aafc4e731d586" + "sizeBytes": 4488, + "sha256": "7dfbf42f5da3cb819883856d0c18166719149511509de1a2ad9eab8bf50e8d58" }, { "path": "src/cli/commands/assemblies.ts", - "sizeBytes": 52644, - "sha256": "06ea627a1d0d29dd8ca853b0a535ee5ab728d56fbeacbe4b65b81c6b90569900" + "sizeBytes": 52099, + "sha256": "4d41d313c6722cb601fa451c9d67a07c65f81659c6bd168205e061b023090bb1" }, { "path": "dist/alphalib/types/assembliesGet.d.ts", @@ -2706,6 +2769,16 @@ "sizeBytes": 2068, "sha256": "08af2039f3e568d27b91508b8002ce2ee19714817d69360a4e942cf27f820657" }, + { + "path": "dist/cli/fileProcessingOptions.d.ts", + "sizeBytes": 1095, + "sha256": "1faaca480253919fde59880952643428df7e387b4837040c77d18874255c0e81" + }, + { + "path": "src/cli/fileProcessingOptions.ts", + "sizeBytes": 2331, + "sha256": "c9fbc2dc5bc2593f298f8ca47091643951bd22c6f08bd138d8ef8ade9c1f9357" + }, { "path": "dist/alphalib/types/robots/ftp-import.d.ts", "sizeBytes": 10382, @@ -2728,13 +2801,13 @@ }, { "path": "dist/cli/commands/generated-intents.d.ts", - "sizeBytes": 261996, - "sha256": "4e1c9ba6760c8e0f237cafbbcbfedf799fac73d3e1aa1f9e8f806a2c9ff16ae9" + "sizeBytes": 265589, + "sha256": "714397e265f3d3c87a085c6e60c1eae9b9ea5b1d9bff344e2172857a0f882d86" }, { "path": "src/cli/commands/generated-intents.ts", - "sizeBytes": 77835, - "sha256": "355aa098c818ed9b822f1b73e29fbc48cef8ffff180dfad38104b95cade90603" + "sizeBytes": 83511, + "sha256": "ae240c3978168433d4dab3ebf24fc230eed59faa50c9288855421e3c04bd2ca8" }, { "path": "dist/alphalib/types/robots/google-import.d.ts", @@ -2898,23 +2971,53 @@ }, { "path": "dist/cli/intentCommandSpecs.d.ts", - "sizeBytes": 1499, - "sha256": "72015b58dfdcfcf52194487a0d72e68eab5917561f28bf8ae2ecb2a6c3319d3b" + "sizeBytes": 1439, + "sha256": "6cc613798ca129ddae21c32e9f41ff1100ec1062b7a692ad407598a047dc5c50" }, { "path": "src/cli/intentCommandSpecs.ts", - "sizeBytes": 9207, - "sha256": "8eff37ffd84202c049ebda475ef22ce5175d474aa84f7b4d30936c0a0b911b14" + "sizeBytes": 7289, + "sha256": "6361d5878bbc63b57abd1eadead9b9627dec0c75054b5c77efbb7f3ac61d75cd" + }, + { + "path": "dist/cli/intentFields.d.ts", + "sizeBytes": 436, + "sha256": "c57fc802ff7528fbb9546869294aeeec3e066e06cadf6856c1bde04a3dc2fcb7" + }, + { + "path": "src/cli/intentFields.ts", + "sizeBytes": 3285, + "sha256": "112fa2f6772eef50f2e7b528c8ba7eb349570e01e84011b30279ada2c34f3009" + }, + { + "path": "dist/cli/intentInputPolicy.d.ts", + "sizeBytes": 333, + "sha256": "d44f15f350569ae0cce2ab042d52a086870d9cdfac36ddc8b10fa64f1c20ec3b" + }, + { + "path": "src/cli/intentInputPolicy.ts", + "sizeBytes": 275, + "sha256": "915772425ea5a963f79b42c13d95077733ea173910e0156a3b93964714c52ead" + }, + { + "path": "dist/cli/intentResolvedDefinitions.d.ts", + "sizeBytes": 2118, + "sha256": "074d9091a432bc7131b45199417343b987a5965d5c465d66f51136cf70684ddd" + }, + { + "path": "src/cli/intentResolvedDefinitions.ts", + "sizeBytes": 14794, + "sha256": "d17db6ffe07012976b8fef6f206f4f50ffb8c01696169cc54f41a87685e2ff10" }, { "path": "dist/cli/intentRuntime.d.ts", - "sizeBytes": 3679, - "sha256": "2941957647d34aad4d05c8e7ead1c56c53253d8794d3fa6b7a2b68be390cdaeb" + "sizeBytes": 4257, + "sha256": "5e205d60b47eaab7562af41bbbff7596345caf84b24debe4fae5d4e9323cbfe9" }, { "path": "src/cli/intentRuntime.ts", - "sizeBytes": 13948, - "sha256": "ee546c1f51c1d896d3176eb5b37eac1a48a7992f8ab7a9ee6dab4a792c42abf6" + "sizeBytes": 13958, + "sha256": "be3abc271b0983b12e6c70b9c4943eda93205199d145f68a29c337b662700242" }, { "path": "dist/cli/intentSmokeCases.d.ts", diff --git a/docs/fingerprint/transloadit-baseline.package.json b/docs/fingerprint/transloadit-baseline.package.json index 0f1dab7b..99acf0ed 100644 --- a/docs/fingerprint/transloadit-baseline.package.json +++ b/docs/fingerprint/transloadit-baseline.package.json @@ -70,8 +70,7 @@ "src": "./src" }, "scripts": { - "sync:intents": "node scripts/generate-intent-commands.ts", - "check": "yarn sync:intents && yarn lint:ts && yarn fix && yarn test:unit", + "check": "yarn lint:ts && yarn test:unit", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", @@ -81,9 +80,9 @@ "lint:deps": "knip --dependencies --no-progress", "fix:deps": "knip --dependencies --no-progress --fix", "prepack": "node ../../scripts/prepare-transloadit.ts", - "test:unit": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage ./test/unit", - "test:e2e": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run ./test/e2e", - "test": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage" + "test:unit": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage --passWithNoTests ./test/unit", + "test:e2e": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --passWithNoTests ./test/e2e", + "test": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage --passWithNoTests" }, "license": "MIT", "main": "./dist/Transloadit.js", diff --git a/scripts/fingerprint-pack.ts b/scripts/fingerprint-pack.ts index beef9e2c..cad2f1a0 100644 --- a/scripts/fingerprint-pack.ts +++ b/scripts/fingerprint-pack.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { resolve } from 'node:path' +import { relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' const execFileAsync = promisify(execFile) @@ -112,6 +112,11 @@ const runWithConcurrency = async ( return results } +const normalizePackageDir = (cwd: string): string => { + const normalized = relative(process.cwd(), cwd).split(sep).join('/') + return normalized === '' ? '.' : normalized +} + const main = async (): Promise => { const { target, out, keep, ignoreScripts, quiet } = parseArgs() const cwd = resolve(process.cwd(), target) @@ -153,7 +158,7 @@ const main = async (): Promise => { const packageJson = JSON.parse(packageJsonRaw) const summary = { - packageDir: cwd, + packageDir: normalizePackageDir(cwd), tarball: { filename: info.filename, sizeBytes: tarballStat.size, From 2762b74a974cefed9e4d1edbc8af2374d96b7e72 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 1 Apr 2026 23:29:41 +0200 Subject: [PATCH 23/44] fix(node): address council review findings --- docs/fingerprint/transloadit-baseline.json | 5 +- .../node/scripts/generate-intent-commands.ts | 16 +- packages/node/scripts/test-intents-e2e.sh | 4 +- packages/node/src/cli/commands/assemblies.ts | 47 +++++- .../src/cli/commands/generated-intents.ts | 139 +++++++++++------ .../node/src/cli/fileProcessingOptions.ts | 6 +- packages/node/src/cli/intentFields.ts | 37 ++++- packages/node/src/cli/intentRuntime.ts | 60 ++++++-- packages/node/src/inputFiles.ts | 111 ++++++++++++-- .../test/unit/cli/assemblies-create.test.ts | 97 ++++++++++++ packages/node/test/unit/cli/intents.test.ts | 144 +++++++++++++++++- packages/node/test/unit/input-files.test.ts | 63 +++++++- 12 files changed, 638 insertions(+), 91 deletions(-) diff --git a/docs/fingerprint/transloadit-baseline.json b/docs/fingerprint/transloadit-baseline.json index 12d7fe1a..cba9f986 100644 --- a/docs/fingerprint/transloadit-baseline.json +++ b/docs/fingerprint/transloadit-baseline.json @@ -13,10 +13,7 @@ ".": "./dist/Transloadit.js", "./package.json": "./package.json" }, - "files": [ - "dist", - "src" - ] + "files": ["dist", "src"] }, "files": [ { diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index a5c3aeb1..4296b2df 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -28,7 +28,18 @@ function formatSchemaFields(fieldSpecs: GeneratedSchemaField[]): string { return fieldSpecs .map((fieldSpec) => { const requiredLine = fieldSpec.required ? '\n required: true,' : '' - return ` ${fieldSpec.propertyName} = Option.String('${fieldSpec.optionFlags}', { + const optionExpression = + fieldSpec.kind === 'boolean' + ? `Option.Boolean('${fieldSpec.optionFlags}', {` + : fieldSpec.kind === 'number' + ? `Option.String('${fieldSpec.optionFlags}', {\n description: ${formatDescription(fieldSpec.description)},${requiredLine}\n validator: t.isNumber(),\n })` + : `Option.String('${fieldSpec.optionFlags}', {` + + if (fieldSpec.kind === 'number') { + return ` ${fieldSpec.propertyName} = ${optionExpression}` + } + + return ` ${fieldSpec.propertyName} = ${optionExpression} description: ${formatDescription(fieldSpec.description)},${requiredLine} })` }) @@ -128,7 +139,7 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { } function formatRawValuesMethod(fieldSpecs: GeneratedSchemaField[]): string { - return ` protected override getIntentRawValues(): Record { + return ` protected override getIntentRawValues(): Record { return ${formatRawValues(fieldSpecs)} }` } @@ -169,6 +180,7 @@ function generateFile(specs: ResolvedIntentCommandSpec[]): string { // Generated by \`packages/node/scripts/generate-intent-commands.ts\`. import { Command, Option } from 'clipanion' +import * as t from 'typanion' ${generateImports(specs)} import { diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index 5cba9b15..62bec8e8 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -149,13 +149,13 @@ run_case() { if [[ $exit_code -eq 0 ]] && verify_output "$verifier" "$output_path"; then verdict='OK' if [[ -f "$output_path" ]]; then - detail="$(file "$output_path" | sed 's#^.*: ##' | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g')" + detail="$(file "$output_path" | sed 's#^.*: ##' | tr '\n' ' ' | awk '{$1=$1; print}')" else detail="$(find "$output_path" -type f | sed "s#^$output_path/##" | sort | tr '\n' ',' | sed 's/,$//')" fi else if [[ -s "$logfile" ]]; then - detail="$(tail -n 8 "$logfile" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g' | cut -c1-220)" + detail="$(tail -n 8 "$logfile" | tr '\n' ' ' | awk '{$1=$1; print}' | cut -c1-220)" else detail='No output captured' fi diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 8494b813..97404ac5 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -634,8 +634,12 @@ async function resolveResultDownloadTargets({ } if (!outputRootIsDirectory) { - if (outputPath == null && allFiles.length > 1) { - throw new Error('stdout can only receive a single result file') + if (allFiles.length > 1) { + if (outputPath == null) { + throw new Error('stdout can only receive a single result file') + } + + throw new Error('file outputs can only receive a single result file') } const first = allFiles[0] @@ -1368,6 +1372,39 @@ export async function create( return assembly } + async function shouldSkipSingleAssemblyRun(inputPaths: string[]): Promise { + if (reprocessStale || resolvedOutput == null || outputRootIsDirectory) { + return false + } + + if (inputPaths.some((inputPath) => inputPath === stdinWithPath.path)) { + return false + } + + const [outputErr, outputStat] = await tryCatch(fsp.stat(resolvedOutput)) + if (outputErr != null || outputStat == null) { + return false + } + + const inputStats = await Promise.all( + inputPaths.map(async (inputPath) => { + const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPath)) + if (inputErr != null || inputStat == null) { + return null + } + return inputStat + }), + ) + + if (inputStats.some((inputStat) => inputStat == null)) { + return false + } + + return inputStats.every((inputStat) => { + return inputStat != null && outputStat.mtime > inputStat.mtime + }) + } + // Helper to process a single assembly job async function processAssemblyJob( inPath: string | null, @@ -1409,6 +1446,12 @@ export async function create( return } + if (await shouldSkipSingleAssemblyRun(collectedPaths)) { + outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`) + resolve({ results: [], hasFailures: false }) + return + } + // Build uploads object, creating fresh streams for each file const uploads: Record = {} const inputPaths: string[] = [] diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index 5a525d8e..20e4081e 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -2,6 +2,7 @@ // Generated by `packages/node/scripts/generate-intent-commands.ts`. import { Command, Option } from 'clipanion' +import * as t from 'typanion' import { robotAudioWaveformInstructionsSchema } from '../../alphalib/types/robots/audio-waveform.ts' import { robotDocumentAutorotateInstructionsSchema } from '../../alphalib/types/robots/document-autorotate.ts' @@ -538,6 +539,7 @@ class ImageGenerateCommand extends GeneratedNoInputIntentCommand { seed = Option.String('--seed', { description: 'Seed for the random number generator.', + validator: t.isNumber(), }) aspectRatio = Option.String('--aspect-ratio', { @@ -546,10 +548,12 @@ class ImageGenerateCommand extends GeneratedNoInputIntentCommand { height = Option.String('--height', { description: 'Height of the generated image.', + validator: t.isNumber(), }) width = Option.String('--width', { description: 'Width of the generated image.', + validator: t.isNumber(), }) style = Option.String('--style', { @@ -558,9 +562,10 @@ class ImageGenerateCommand extends GeneratedNoInputIntentCommand { numOutputs = Option.String('--num-outputs', { description: 'Number of image variants to generate.', + validator: t.isNumber(), }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { model: this.model, prompt: this.prompt, @@ -598,10 +603,12 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { width = Option.String('--width', { description: 'Width of the thumbnail, in pixels.', + validator: t.isNumber(), }) height = Option.String('--height', { description: 'Height of the thumbnail, in pixels.', + validator: t.isNumber(), }) resizeStrategy = Option.String('--resize-strategy', { @@ -640,11 +647,13 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { waveformHeight = Option.String('--waveform-height', { description: 'Height of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', + validator: t.isNumber(), }) waveformWidth = Option.String('--waveform-width', { description: 'Width of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', + validator: t.isNumber(), }) iconStyle = Option.String('--icon-style', { @@ -667,7 +676,7 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { 'The content of the text box in generated icons. Only used if the `icon_style` parameter is set to `with-text`. The default value, `extension`, adds the file extension (e.g. MP4, JPEG) to the icon. The value `none` can be used to render an empty text box, which is useful if no text should not be included in the raster image, but some place should be reserved in the image for later overlaying custom text over the image using HTML etc.', }) - optimize = Option.String('--optimize', { + optimize = Option.Boolean('--optimize', { description: "Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. If enabled, the images will be optimized using [🤖/image/optimize](/docs/robots/image-optimize/).", }) @@ -677,7 +686,7 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { 'Specifies whether conversion speed or compression ratio is prioritized when optimizing images. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-priority) for more details.', }) - optimizeProgressive = Option.String('--optimize-progressive', { + optimizeProgressive = Option.Boolean('--optimize-progressive', { description: 'Specifies whether images should be interlaced, which makes the result image load progressively in browsers. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-progressive) for more details.', }) @@ -690,24 +699,27 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { clipOffset = Option.String('--clip-offset', { description: 'The start position in seconds of where the clip is cut. Only used if the `clip` strategy for video files is applied. Be aware that for larger video only the first few MBs of the file may be imported to improve speed. Larger offsets may seek to a position outside of the imported part and thus fail to generate a clip.', + validator: t.isNumber(), }) clipDuration = Option.String('--clip-duration', { description: 'The duration in seconds of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a longer clip duration also results in a larger file size, which might be undesirable for previews.', + validator: t.isNumber(), }) clipFramerate = Option.String('--clip-framerate', { description: 'The framerate of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a higher framerate appears smoother but also results in a larger file size, which might be undesirable for previews.', + validator: t.isNumber(), }) - clipLoop = Option.String('--clip-loop', { + clipLoop = Option.Boolean('--clip-loop', { description: 'Specifies whether the generated animated image should loop forever (`true`) or stop after playing the animation once (`false`). Only used if the `clip` strategy for video files is applied.', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { format: this.format, width: this.width, @@ -770,7 +782,7 @@ class ImageRemoveBackgroundCommand extends GeneratedStandardFileIntentCommand { 'Provider-specific model to use for removing the background. Mostly intended for testing and evaluation.', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { select: this.select, format: this.format, @@ -801,22 +813,22 @@ class ImageOptimizeCommand extends GeneratedStandardFileIntentCommand { 'Provides different algorithms for better or worse compression for your images, but that run slower or faster. The value `"conversion-speed"` will result in an average compression ratio of 18%. `"compression-ratio"` will result in an average compression ratio of 31%.', }) - progressive = Option.String('--progressive', { + progressive = Option.Boolean('--progressive', { description: 'Interlaces the image if set to `true`, which makes the result image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the image which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a loss of about 10% of the file size reduction.', }) - preserveMetaData = Option.String('--preserve-meta-data', { + preserveMetaData = Option.Boolean('--preserve-meta-data', { description: "Specifies if the image's metadata should be preserved during the optimization, or not. If it is not preserved, the file size is even further reduced. But be aware that this could strip a photographer's copyright information, which for obvious reasons can be frowned upon.", }) - fixBreakingImages = Option.String('--fix-breaking-images', { + fixBreakingImages = Option.Boolean('--fix-breaking-images', { description: 'If set to `true` this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies. This can sometimes result in a larger file size, though.', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { priority: this.priority, progressive: this.progressive, @@ -848,18 +860,20 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { width = Option.String('--width', { description: 'Width of the result in pixels. If not specified, will default to the width of the original.', + validator: t.isNumber(), }) height = Option.String('--height', { description: 'Height of the new image, in pixels. If not specified, will default to the height of the input image.', + validator: t.isNumber(), }) resizeStrategy = Option.String('--resize-strategy', { description: 'See the list of available [resize strategies](/docs/topics/resize-strategies/).', }) - zoom = Option.String('--zoom', { + zoom = Option.Boolean('--zoom', { description: 'If this is set to `false`, smaller images will not be stretched to the desired width and height. For details about the impact of zooming for your preferred resize strategy, see the list of available [resize strategies](/docs/topics/resize-strategies/).', }) @@ -874,7 +888,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined.', }) - strip = Option.String('--strip', { + strip = Option.Boolean('--strip', { description: 'Strips all metadata from the image. This is useful to keep thumbnails as small as possible.', }) @@ -888,12 +902,12 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`.', }) - flatten = Option.String('--flatten', { + flatten = Option.Boolean('--flatten', { description: 'Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#layers).\n\nTo preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter.', }) - correctGamma = Option.String('--correct-gamma', { + correctGamma = Option.Boolean('--correct-gamma', { description: 'Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html).', }) @@ -901,9 +915,10 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { quality = Option.String('--quality', { description: 'Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', + validator: t.isNumber(), }) - adaptiveFiltering = Option.String('--adaptive-filtering', { + adaptiveFiltering = Option.Boolean('--adaptive-filtering', { description: 'Controls the image compression for PNG images. Setting to `true` results in smaller file size, while increasing processing time. It is encouraged to keep this option disabled.', }) @@ -916,6 +931,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { frame = Option.String('--frame', { description: 'Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. Specify `1` to use the first frame, `2` to use the second, and so on. `null` means all frames.', + validator: t.isNumber(), }) colorspace = Option.String('--colorspace', { @@ -930,6 +946,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { sepia = Option.String('--sepia', { description: 'Applies a sepia tone effect in percent.', + validator: t.isNumber(), }) rotation = Option.String('--rotation', { @@ -955,21 +972,25 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { brightness = Option.String('--brightness', { description: 'Increases or decreases the brightness of the image by using a multiplier. For example `1.5` would increase the brightness by 50%, and `0.75` would decrease the brightness by 25%.', + validator: t.isNumber(), }) saturation = Option.String('--saturation', { description: 'Increases or decreases the saturation of the image by using a multiplier. For example `1.5` would increase the saturation by 50%, and `0.75` would decrease the saturation by 25%.', + validator: t.isNumber(), }) hue = Option.String('--hue', { description: 'Changes the hue by rotating the color of the image. The value `100` would produce no change whereas `0` and `200` will negate the colors in the image.', + validator: t.isNumber(), }) contrast = Option.String('--contrast', { description: 'Adjusts the contrast of the image. A value of `1` produces no change. Values below `1` decrease contrast (with `0` being minimum contrast), and values above `1` increase contrast (with `2` being maximum contrast). This works like the `brightness` parameter.', + validator: t.isNumber(), }) watermarkUrl = Option.String('--watermark-url', { @@ -985,11 +1006,13 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { watermarkXOffset = Option.String('--watermark-x-offset', { description: "The x-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", + validator: t.isNumber(), }) watermarkYOffset = Option.String('--watermark-y-offset', { description: "The y-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", + validator: t.isNumber(), }) watermarkSize = Option.String('--watermark-size', { @@ -1005,14 +1028,15 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { watermarkOpacity = Option.String('--watermark-opacity', { description: 'The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque.\n\nFor example, a value of `0.5` means the watermark will be 50% transparent, allowing the underlying image to show through. This is useful for subtle branding or when you want the watermark to be less obtrusive.', + validator: t.isNumber(), }) - watermarkRepeatX = Option.String('--watermark-repeat-x', { + watermarkRepeatX = Option.Boolean('--watermark-repeat-x', { description: 'When set to `true`, the watermark will be repeated horizontally across the entire width of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark.', }) - watermarkRepeatY = Option.String('--watermark-repeat-y', { + watermarkRepeatY = Option.Boolean('--watermark-repeat-y', { description: 'When set to `true`, the watermark will be repeated vertically across the entire height of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image. Can be combined with `watermark_repeat_x` to tile in both directions.', }) @@ -1022,7 +1046,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'Text overlays to be applied to the image. Can be either a single text object or an array of text objects. Each text object contains text rules. The following text parameters are intended to be used as properties for your text overlays. Here is an example:\n\n```json\n"watermarked": {\n "use": "resized",\n "robot": "/image/resize",\n "text": [\n {\n "text": "© 2018 Transloadit.com",\n "size": 12,\n "font": "Ubuntu",\n "color": "#eeeeee",\n "valign": "bottom",\n "align": "right",\n "x_offset": 16,\n "y_offset": -10\n }\n ]\n}\n```', }) - progressive = Option.String('--progressive', { + progressive = Option.Boolean('--progressive', { description: 'Interlaces the image if set to `true`, which makes the image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the images which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a cost of a file size increase by around 10%.', }) @@ -1031,7 +1055,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { description: 'Make this color transparent within the image. Example: `"255,255,255"`.', }) - trimWhitespace = Option.String('--trim-whitespace', { + trimWhitespace = Option.Boolean('--trim-whitespace', { description: 'This determines if additional whitespace around the image should first be trimmed away. If you set this to `true` this parameter removes any edges that are exactly the same color as the corner pixels.', }) @@ -1041,7 +1065,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'Apply the clipping path to other operations in the resize job, if one is present. If set to `true`, it will automatically take the first clipping path. If set to a String it finds a clipping path by that name.', }) - negate = Option.String('--negate', { + negate = Option.Boolean('--negate', { description: 'Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping.', }) @@ -1051,7 +1075,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image is unsharp, please try increasing density.', }) - monochrome = Option.String('--monochrome', { + monochrome = Option.Boolean('--monochrome', { description: 'Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel.', }) @@ -1061,7 +1085,7 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { 'Shave pixels from the image edges. The value should be in the format `width` or `width`x`height` to specify the number of pixels to remove from each side.', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { format: this.format, width: this.width, @@ -1151,7 +1175,7 @@ class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { 'PDF Paper margins, separated by `,` and with units.\n\nWe support the following unit values: `px`, `in`, `cm`, `mm`.\n\nCurrently this parameter is only supported when converting from `html`.', }) - pdfPrintBackground = Option.String('--pdf-print-background', { + pdfPrintBackground = Option.Boolean('--pdf-print-background', { description: 'Print PDF background graphics.\n\nCurrently this parameter is only supported when converting from `html`.', }) @@ -1161,7 +1185,7 @@ class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { 'PDF paper format.\n\nCurrently this parameter is only supported when converting from `html`.', }) - pdfDisplayHeaderFooter = Option.String('--pdf-display-header-footer', { + pdfDisplayHeaderFooter = Option.Boolean('--pdf-display-header-footer', { description: 'Display PDF header and footer.\n\nCurrently this parameter is only supported when converting from `html`.', }) @@ -1176,7 +1200,7 @@ class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { 'HTML template for the PDF print footer.\n\nShould use the same format as the `pdf_header_template`.\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number in the footer you\'d use the following HTML for the footer template:\n\n```html\n
\n```', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { format: this.format, markdown_format: this.markdownFormat, @@ -1215,24 +1239,25 @@ class DocumentOptimizeCommand extends GeneratedStandardFileIntentCommand { imageDpi = Option.String('--image-dpi', { description: 'Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset.\n\nHigher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed.\n\nCommon values:\n- 72 - Screen viewing\n- 150 - eBooks and general documents\n- 300 - Print quality\n- 600 - High-quality print', + validator: t.isNumber(), }) - compressFonts = Option.String('--compress-fonts', { + compressFonts = Option.Boolean('--compress-fonts', { description: 'Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size.', }) - subsetFonts = Option.String('--subset-fonts', { + subsetFonts = Option.Boolean('--subset-fonts', { description: "Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. This can significantly reduce file size for documents that only use a small portion of a font's character set.", }) - removeMetadata = Option.String('--remove-metadata', { + removeMetadata = Option.Boolean('--remove-metadata', { description: 'Whether to strip document metadata (title, author, keywords, etc.) from the PDF. This can provide a small reduction in file size and may be useful for privacy.', }) - linearize = Option.String('--linearize', { + linearize = Option.Boolean('--linearize', { description: 'Whether to linearize (optimize for Fast Web View) the output PDF. Linearized PDFs can begin displaying in a browser before they are fully downloaded, improving the user experience for web delivery.', }) @@ -1242,7 +1267,7 @@ class DocumentOptimizeCommand extends GeneratedStandardFileIntentCommand { 'The PDF version compatibility level. Lower versions have broader compatibility but fewer features. Higher versions support more advanced features but may not open in older PDF readers.\n\n- `1.4` - Acrobat 5 compatibility, most widely supported\n- `1.5` - Acrobat 6 compatibility\n- `1.6` - Acrobat 7 compatibility\n- `1.7` - Acrobat 8+ compatibility (default)\n- `2.0` - PDF 2.0 standard', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { preset: this.preset, image_dpi: this.imageDpi, @@ -1271,7 +1296,7 @@ class DocumentAutoRotateCommand extends GeneratedStandardFileIntentCommand { return documentAutoRotateCommandDefinition } - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return {} } } @@ -1293,6 +1318,7 @@ class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { page = Option.String('--page', { description: 'The PDF page that you want to convert to an image. By default the value is `null` which means that all pages will be converted into images.', + validator: t.isNumber(), }) format = Option.String('--format', { @@ -1303,16 +1329,19 @@ class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { delay = Option.String('--delay', { description: 'If your output format is `"gif"` then this parameter sets the number of 100th seconds to pass before the next frame is shown in the animation. Set this to `100` for example to allow 1 second to pass between the frames of the animated gif.\n\nIf your output format is not `"gif"`, then this parameter does not have any effect.', + validator: t.isNumber(), }) width = Option.String('--width', { description: 'Width of the new image, in pixels. If not specified, will default to the width of the input image', + validator: t.isNumber(), }) height = Option.String('--height', { description: 'Height of the new image, in pixels. If not specified, will default to the height of the input image', + validator: t.isNumber(), }) resizeStrategy = Option.String('--resize-strategy', { @@ -1334,7 +1363,7 @@ class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image has a low resolution, please try using the density parameter to resolve that.', }) - antialiasing = Option.String('--antialiasing', { + antialiasing = Option.Boolean('--antialiasing', { description: 'Controls whether or not antialiasing is used to remove jagged edges from text or images in a document.', }) @@ -1344,22 +1373,22 @@ class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace).\n\nPlease note that if you were using `"RGB"`, we recommend using `"sRGB"`. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might then have to use this parameter.', }) - trimWhitespace = Option.String('--trim-whitespace', { + trimWhitespace = Option.Boolean('--trim-whitespace', { description: "This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. If you set this to `true` only the real PDF page contents will be shown in the image.\n\nIf you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`.", }) - pdfUseCropbox = Option.String('--pdf-use-cropbox', { + pdfUseCropbox = Option.Boolean('--pdf-use-cropbox', { description: "Some PDF documents lie about their dimensions. For instance they'll say they are landscape, but when opened in decent Desktop readers, it's really in portrait mode. This can happen if the document has a cropbox defined. When this option is enabled (by default), the cropbox is leading in determining the dimensions of the resulting thumbnails.", }) - turbo = Option.String('--turbo', { + turbo = Option.Boolean('--turbo', { description: "If you set this to `false`, the robot will not emit files as they become available. This is useful if you are only interested in the final result and not in the intermediate steps.\n\nAlso, extracted pages will be resized a lot faster as they are sent off to other machines for the resizing. This is especially useful for large documents with many pages to get up to 20 times faster processing.\n\nTurbo Mode increases pricing, though, in that the input document's file size is added for every extracted page. There are no performance benefits nor increased charges for single-page documents.", }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { page: this.page, format: this.format, @@ -1407,10 +1436,12 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { width = Option.String('--width', { description: 'The width of the resulting image if the format `"image"` was selected.', + validator: t.isNumber(), }) height = Option.String('--height', { description: 'The height of the resulting image if the format `"image"` was selected.', + validator: t.isNumber(), }) antialiasing = Option.String('--antialiasing', { @@ -1438,7 +1469,7 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', }) - splitChannels = Option.String('--split-channels', { + splitChannels = Option.Boolean('--split-channels', { description: 'Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel.', }) @@ -1446,23 +1477,28 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { zoom = Option.String('--zoom', { description: 'Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`.', + validator: t.isNumber(), }) pixelsPerSecond = Option.String('--pixels-per-second', { description: 'Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`.', + validator: t.isNumber(), }) bits = Option.String('--bits', { description: 'Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16.', + validator: t.isNumber(), }) start = Option.String('--start', { description: 'Available when style is `"v1"`. Start time in seconds.', + validator: t.isNumber(), }) end = Option.String('--end', { description: 'Available when style is `"v1"`. End time in seconds (0 means end of audio).', + validator: t.isNumber(), }) colors = Option.String('--colors', { @@ -1481,11 +1517,13 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { barWidth = Option.String('--bar-width', { description: 'Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars".', + validator: t.isNumber(), }) barGap = Option.String('--bar-gap', { description: 'Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars".', + validator: t.isNumber(), }) barStyle = Option.String('--bar-style', { @@ -1496,26 +1534,28 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { description: 'Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format.', }) - noAxisLabels = Option.String('--no-axis-labels', { + noAxisLabels = Option.Boolean('--no-axis-labels', { description: 'Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels.', }) - withAxisLabels = Option.String('--with-axis-labels', { + withAxisLabels = Option.Boolean('--with-axis-labels', { description: 'Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels.', }) amplitudeScale = Option.String('--amplitude-scale', { description: 'Available when style is `"v1"`. Amplitude scale factor.', + validator: t.isNumber(), }) compression = Option.String('--compression', { description: 'Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image".', + validator: t.isNumber(), }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { ffmpeg: this.ffmpeg, format: this.format, @@ -1587,12 +1627,12 @@ class TextSpeakCommand extends GeneratedStandardFileIntentCommand { 'The gender to be used for voice synthesis. Please consult the list of supported languages and voices.', }) - ssml = Option.String('--ssml', { + ssml = Option.Boolean('--ssml', { description: 'Supply [Speech Synthesis Markup Language](https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language) instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations.\n\nPlease see the supported syntaxes for [AWS](https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html) and [GCP](https://cloud.google.com/text-to-speech/docs/ssml).', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { prompt: this.prompt, provider: this.provider, @@ -1625,6 +1665,7 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { count = Option.String('--count', { description: 'The number of thumbnails to be extracted. As some videos have incorrect durations, the actual number of thumbnails generated may be less in rare cases. The maximum number of thumbnails we currently allow is 999.\n\nThe thumbnails are taken at regular intervals, determined by dividing the video duration by the count. For example, a count of 3 will produce thumbnails at 25%, 50% and 75% through the video.\n\nTo extract thumbnails for specific timestamps, use the `offsets` parameter.', + validator: t.isNumber(), }) offsets = Option.String('--offsets', { @@ -1640,11 +1681,13 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { width = Option.String('--width', { description: 'The width of the thumbnail, in pixels. Defaults to the original width of the video.', + validator: t.isNumber(), }) height = Option.String('--height', { description: 'The height of the thumbnail, in pixels. Defaults to the original height of the video.', + validator: t.isNumber(), }) resizeStrategy = Option.String('--resize-strategy', { @@ -1659,6 +1702,7 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { rotate = Option.String('--rotate', { description: 'Forces the video to be rotated by the specified degree integer. Currently, only multiples of 90 are supported. We automatically correct the orientation of many videos when the orientation is provided by the camera. This option is only useful for videos requiring rotation because it was not detected by the camera.', + validator: t.isNumber(), }) inputCodec = Option.String('--input-codec', { @@ -1666,7 +1710,7 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { 'Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders.', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { ffmpeg: this.ffmpeg, count: this.count, @@ -1697,7 +1741,7 @@ class VideoEncodeHlsCommand extends GeneratedStandardFileIntentCommand { return videoEncodeHlsCommandDefinition } - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return {} } } @@ -1723,7 +1767,7 @@ class FileCompressCommand extends GeneratedBundledFileIntentCommand { 'The format of the archive to be created. Supported values are `"tar"` and `"zip"`.\n\nNote that `"tar"` without setting `gzip` to `true` results in an archive that\'s not compressed in any way.', }) - gzip = Option.String('--gzip', { + gzip = Option.Boolean('--gzip', { description: 'Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format.', }) @@ -1736,6 +1780,7 @@ class FileCompressCommand extends GeneratedBundledFileIntentCommand { compressionLevel = Option.String('--compression-level', { description: 'Determines how fiercely to try to compress the archive. `-0` is compressionless, which is suitable for media that is already compressed. `-1` is fastest with lowest compression. `-9` is slowest with the highest compression.\n\nIf you are using `-0` in combination with the `tar` format with `gzip` enabled, consider setting `gzip: false` instead. This results in a plain Tar archive, meaning it already has no compression.', + validator: t.isNumber(), }) fileLayout = Option.String('--file-layout', { @@ -1747,7 +1792,7 @@ class FileCompressCommand extends GeneratedBundledFileIntentCommand { description: 'The name of the archive file to be created (without the file extension).', }) - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return { format: this.format, gzip: this.gzip, @@ -1773,7 +1818,7 @@ class FileDecompressCommand extends GeneratedStandardFileIntentCommand { return fileDecompressCommandDefinition } - protected override getIntentRawValues(): Record { + protected override getIntentRawValues(): Record { return {} } } diff --git a/packages/node/src/cli/fileProcessingOptions.ts b/packages/node/src/cli/fileProcessingOptions.ts index bce56c92..6ccc4de0 100644 --- a/packages/node/src/cli/fileProcessingOptions.ts +++ b/packages/node/src/cli/fileProcessingOptions.ts @@ -52,11 +52,11 @@ export function singleAssemblyOption( export function concurrencyOption( description = 'Maximum number of concurrent assemblies (default: 5)', -): string | undefined { +): number | undefined { return Option.String('--concurrency,-c', { description, - validator: t.isNumber(), - }) as unknown as string | undefined + validator: t.applyCascade(t.isNumber(), [t.isAtLeast(1)]), + }) as unknown as number | undefined } export function countProvidedInputs({ diff --git a/packages/node/src/cli/intentFields.ts b/packages/node/src/cli/intentFields.ts index 6b706206..de572fa3 100644 --- a/packages/node/src/cli/intentFields.ts +++ b/packages/node/src/cli/intentFields.ts @@ -61,18 +61,31 @@ export function inferIntentFieldKind(schema: unknown): IntentFieldKind { export function coerceIntentFieldValue( kind: IntentFieldKind, - raw: string, + raw: unknown, fieldSchema?: z.ZodTypeAny, ): unknown { + if (kind === 'number' && typeof raw === 'number') { + return raw + } + + if (kind === 'boolean' && typeof raw === 'boolean') { + return raw + } + if (kind === 'auto') { if (fieldSchema == null) { return raw } - const trimmed = raw.trim() const candidates: unknown[] = [] - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + if (typeof raw !== 'string') { + candidates.push(raw) + } + + const trimmed = typeof raw === 'string' ? raw.trim() : '' + + if (typeof raw === 'string' && (trimmed.startsWith('{') || trimmed.startsWith('['))) { try { candidates.push(JSON.parse(trimmed)) } catch {} @@ -80,7 +93,12 @@ export function coerceIntentFieldValue( candidates.push(raw) - if (trimmed !== '' && !trimmed.startsWith('{') && !trimmed.startsWith('[')) { + if ( + typeof raw === 'string' && + trimmed !== '' && + !trimmed.startsWith('{') && + !trimmed.startsWith('[') + ) { try { candidates.push(JSON.parse(trimmed)) } catch {} @@ -91,7 +109,7 @@ export function coerceIntentFieldValue( } const numericValue = Number(raw) - if (raw.trim() !== '' && !Number.isNaN(numericValue)) { + if ((typeof raw === 'number' || trimmed !== '') && !Number.isNaN(numericValue)) { candidates.push(numericValue) } @@ -106,6 +124,9 @@ export function coerceIntentFieldValue( } if (kind === 'number') { + if (typeof raw !== 'string') { + throw new Error(`Expected a number but received "${String(raw)}"`) + } if (raw.trim() === '') { throw new Error(`Expected a number but received "${raw}"`) } @@ -117,6 +138,9 @@ export function coerceIntentFieldValue( } if (kind === 'json') { + if (typeof raw !== 'string') { + return raw + } let parsedJson: unknown try { parsedJson = JSON.parse(raw) @@ -137,6 +161,9 @@ export function coerceIntentFieldValue( } if (kind === 'boolean') { + if (typeof raw !== 'string') { + throw new Error(`Expected "true" or "false" but received "${String(raw)}"`) + } if (raw === 'true') return true if (raw === 'false') return false throw new Error(`Expected "true" or "false" but received "${raw}"`) diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index ece508fa..40b66c44 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -1,3 +1,4 @@ +import { statSync } from 'node:fs' import { basename } from 'node:path' import { Option } from 'clipanion' import type { z } from 'zod' @@ -177,7 +178,7 @@ export function parseIntentStep({ }: { fieldSpecs: readonly IntentFieldSpec[] fixedValues: Record - rawValues: Record + rawValues: Record schema: TSchema }): z.input { const input: Record = { ...fixedValues } @@ -223,7 +224,7 @@ function resolveSingleStepFixedValues( function createSingleStep( execution: IntentSingleStepExecutionDefinition, inputPolicy: IntentInputPolicy, - rawValues: Record, + rawValues: Record, hasInputs: boolean, ): z.input { return parseIntentStep({ @@ -236,7 +237,7 @@ function createSingleStep( function requiresLocalInput( inputPolicy: IntentInputPolicy, - rawValues: Record, + rawValues: Record, ): boolean { if (inputPolicy.kind === 'required') { return true @@ -258,7 +259,7 @@ async function executeFileIntentCommand({ definition: IntentFileCommandDefinition output: AuthenticatedCommand['output'] outputPath: string - rawValues: Record + rawValues: Record }): Promise { const executionOptions = definition.execution.kind === 'template' @@ -295,7 +296,7 @@ abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { | IntentFileCommandDefinition | IntentNoInputCommandDefinition - protected abstract getIntentRawValues(): Record + protected abstract getIntentRawValues(): Record private getOutputDescription(): string { return this.getIntentDefinition().outputDescription @@ -366,9 +367,14 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase }) } - protected validateInputPresence( - rawValues: Record, - ): number | undefined { + protected hasTransientInputSources(): boolean { + return ( + (this.inputs?.some((input) => isHttpUrl(input)) ?? false) || + (this.inputBase64?.length ?? 0) > 0 + ) + } + + protected validateInputPresence(rawValues: Record): number | undefined { const intentDefinition = this.getIntentDefinition() const inputCount = this.getProvidedInputCount() if (inputCount !== 0) { @@ -390,9 +396,7 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase return 1 } - protected validateBeforePreparingInputs( - rawValues: Record, - ): number | undefined { + protected validateBeforePreparingInputs(rawValues: Record): number | undefined { return this.validateInputPresence(rawValues) } @@ -401,7 +405,7 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase } protected async executePreparedInputs( - rawValues: Record, + rawValues: Record, preparedInputs: PreparedIntentInputs, ): Promise { return await executeFileIntentCommand({ @@ -447,14 +451,14 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn ): Omit { return { ...super.getCreateOptions(inputs), - concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + concurrency: this.concurrency, singleAssembly: this.singleAssembly, watch: this.watch, } } protected override validateBeforePreparingInputs( - rawValues: Record, + rawValues: Record, ): number | undefined { const validationError = this.validateInputPresence(rawValues) if (validationError != null) { @@ -472,6 +476,22 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn return 1 } + if (this.watch && this.hasTransientInputSources()) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } + + if ( + this.singleAssembly && + this.getProvidedInputCount() > 1 && + !this.isDirectoryOutputTarget() + ) { + this.output.error( + 'Output must be a directory when using --single-assembly with multiple inputs', + ) + return 1 + } + return undefined } @@ -484,6 +504,18 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn } return undefined } + + private isDirectoryOutputTarget(): boolean { + if (this.getIntentDefinition().outputMode === 'directory') { + return true + } + + try { + return statSync(this.outputPath).isDirectory() + } catch { + return false + } + } } export abstract class GeneratedBundledFileIntentCommand extends GeneratedFileIntentCommandBase { diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index 00f7acdf..c77958d3 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -1,3 +1,4 @@ +import * as dnsPromises from 'node:dns/promises' import { createWriteStream } from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { isIP } from 'node:net' @@ -74,6 +75,22 @@ const ensureUniqueStepName = (baseName: string, used: Set): string => { return name } +const ensureUniqueTempFilePath = (root: string, filename: string, used: Set): string => { + const parsed = basename(filename) + const extension = parsed.includes('.') ? `.${parsed.split('.').slice(1).join('.')}` : '' + const stem = extension === '' ? parsed : parsed.slice(0, -extension.length) + + let candidate = join(root, parsed) + let counter = 1 + while (used.has(candidate)) { + candidate = join(root, `${stem}-${counter}${extension}`) + counter += 1 + } + + used.add(candidate) + return candidate +} + const decodeBase64 = (value: string): Buffer => Buffer.from(value, 'base64') const estimateBase64DecodedBytes = (value: string): number => { @@ -106,9 +123,14 @@ const findImportStepName = (field: string, steps: Record): stri return null } -const downloadUrlToFile = async (url: string, filePath: string): Promise => { - await pipeline(got.stream(url), createWriteStream(filePath)) -} +const MAX_URL_REDIRECTS = 10 + +const isRedirectStatusCode = (statusCode: number): boolean => + statusCode === 301 || + statusCode === 302 || + statusCode === 303 || + statusCode === 307 || + statusCode === 308 const isPrivateIp = (address: string): boolean => { if (address === 'localhost') return true @@ -134,7 +156,7 @@ const isPrivateIp = (address: string): boolean => { return false } -const assertPublicDownloadUrl = (value: string): void => { +const assertPublicDownloadUrl = async (value: string): Promise => { const parsed = new URL(value) if (!['http:', 'https:'].includes(parsed.protocol)) { throw new Error(`URL downloads are limited to http/https: ${value}`) @@ -142,6 +164,73 @@ const assertPublicDownloadUrl = (value: string): void => { if (isPrivateIp(parsed.hostname)) { throw new Error(`URL downloads are limited to public hosts: ${value}`) } + + const resolvedAddresses = await dnsPromises.lookup(parsed.hostname, { + all: true, + verbatim: true, + }) + if (resolvedAddresses.some((address) => isPrivateIp(address.address))) { + throw new Error(`URL downloads are limited to public hosts: ${value}`) + } +} + +const downloadUrlToFile = async ({ + allowPrivateUrls, + filePath, + url, +}: { + allowPrivateUrls: boolean + filePath: string + url: string +}): Promise => { + let currentUrl = url + + for (let redirectCount = 0; redirectCount <= MAX_URL_REDIRECTS; redirectCount += 1) { + if (!allowPrivateUrls) { + await assertPublicDownloadUrl(currentUrl) + } + + const responseStream = got.stream(currentUrl, { + followRedirect: false, + retry: { limit: 0 }, + throwHttpErrors: false, + }) + + const response = await new Promise< + Readable & { headers: Record; statusCode?: number } + >((resolvePromise, reject) => { + responseStream.once('response', (incomingResponse) => { + resolvePromise( + incomingResponse as Readable & { + headers: Record + statusCode?: number + }, + ) + }) + responseStream.once('error', reject) + }) + + const statusCode = response.statusCode ?? 0 + if (isRedirectStatusCode(statusCode)) { + responseStream.destroy() + const location = response.headers.location + if (location == null) { + throw new Error(`Redirect response missing Location header: ${currentUrl}`) + } + currentUrl = new URL(location, currentUrl).toString() + continue + } + + if (statusCode >= 400) { + responseStream.destroy() + throw new Error(`Failed to download URL: ${currentUrl} (${statusCode})`) + } + + await pipeline(responseStream, createWriteStream(filePath)) + return + } + + throw new Error(`Too many redirects while downloading URL input: ${url}`) } export const prepareInputFiles = async ( @@ -176,6 +265,7 @@ export const prepareInputFiles = async ( const steps = isRecord(nextParams.steps) ? { ...nextParams.steps } : {} const usedSteps = new Set(Object.keys(steps)) const usedFields = new Set() + const usedTempPaths = new Set() const importUrlsByStep = new Map() const importStepNames = Object.keys(steps).filter((name) => isHttpImportStep(steps[name])) const sharedImportStep = importStepNames.length === 1 ? importStepNames[0] : null @@ -211,7 +301,7 @@ export const prepareInputFiles = async ( if (base64Strategy === 'tempfile') { const root = await ensureTempRoot() const filename = file.filename ? basename(file.filename) : `${file.field}.bin` - const filePath = join(root, filename) + const filePath = ensureUniqueTempFilePath(root, filename, usedTempPaths) await writeFile(filePath, buffer) files[file.field] = filePath } else { @@ -238,11 +328,12 @@ export const prepareInputFiles = async ( (file.filename ? basename(file.filename) : null) ?? getFilenameFromUrl(file.url) ?? `${file.field}.bin` - const filePath = join(root, filename) - if (!allowPrivateUrls) { - assertPublicDownloadUrl(file.url) - } - await downloadUrlToFile(file.url, filePath) + const filePath = ensureUniqueTempFilePath(root, filename, usedTempPaths) + await downloadUrlToFile({ + allowPrivateUrls, + filePath, + url: file.url, + }) files[file.field] = filePath } } diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 56c3251b..a59c3be9 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -179,6 +179,51 @@ describe('assemblies create', () => { expect(stdoutWrite).not.toHaveBeenCalled() }) + it('rejects file outputs when an assembly returns multiple files', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-file-output-multi-') + const outputPath = path.join(tempDir, 'result.txt') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-file-multi' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + generated: [ + { url: 'http://downloads.test/result-a.txt', name: 'a.txt' }, + { url: 'http://downloads.test/result-b.txt', name: 'b.txt' }, + ], + }, + }), + } + + nock('http://downloads.test').get('/result-a.txt').reply(200, 'result-a') + nock('http://downloads.test').get('/result-b.txt').reply(200, 'result-b') + + await expect( + create(output, client as never, { + inputs: [], + output: outputPath, + stepsData: { + generated: { + robot: '/image/generate', + result: true, + prompt: 'hello', + model: 'flux-schnell', + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: true, + }), + ) + + await expect(stat(outputPath)).rejects.toThrow() + }) + it('supports bundled single-assembly outputs written to a file path', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -283,6 +328,58 @@ describe('assemblies create', () => { expect(Object.keys(uploads ?? {}).sort()).toEqual(['a.txt', 'b.txt']) }) + it('skips bundled single-assembly runs when the output is newer than every input', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-bundle-skip-stale-') + const inputA = path.join(tempDir, 'a.txt') + const inputB = path.join(tempDir, 'b.txt') + const outputPath = path.join(tempDir, 'bundle.zip') + + await writeFile(inputA, 'a') + await writeFile(inputB, 'b') + await writeFile(outputPath, 'existing-bundle') + + const inputTime = new Date('2026-01-01T00:00:00.000Z') + const outputTime = new Date('2026-01-01T00:00:10.000Z') + + await utimes(inputA, inputTime, inputTime) + await utimes(inputB, inputTime, inputTime) + await utimes(outputPath, outputTime, outputTime) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn(), + awaitAssemblyCompletion: vi.fn(), + } + + await expect( + create(output, client as never, { + inputs: [inputA, inputB], + output: outputPath, + singleAssembly: true, + stepsData: { + compressed: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + results: [], + }), + ) + + expect(client.createAssembly).not.toHaveBeenCalled() + expect(await readFile(outputPath, 'utf8')).toBe('existing-bundle') + }) + it('rewrites existing bundled outputs on single-assembly reruns', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 7cc0f36e..b80c254f 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -16,11 +19,18 @@ import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' const noopWrite = () => true +const tempDirs: string[] = [] const resetExitCode = () => { process.exitCode = undefined } +async function createTempDir(prefix: string): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), prefix)) + tempDirs.push(tempDir) + return tempDir +} + function getIntentCommand(paths: string[]): (typeof intentCommands)[number] { const command = intentCommands.find((candidate) => { const candidatePaths = candidate.paths[0] @@ -53,6 +63,9 @@ afterEach(() => { vi.unstubAllEnvs() nock.cleanAll() resetExitCode() + return Promise.all( + tempDirs.splice(0).map((tempDir) => rm(tempDir, { recursive: true, force: true })), + ) }) describe('intent commands', () => { @@ -193,6 +206,33 @@ describe('intent commands', () => { ).rejects.toThrow('URL downloads are limited to public hosts') }) + it('keeps duplicate remote basenames as distinct temp inputs', async () => { + nock('http://198.51.100.10').get('/nested/file.pdf').reply(200, 'first-file') + nock('http://198.51.100.11').get('/other/file.pdf').reply(200, 'second-file') + + const prepared = await prepareIntentInputs({ + inputValues: ['http://198.51.100.10/nested/file.pdf', 'http://198.51.100.11/other/file.pdf'], + inputBase64Values: [], + }) + + try { + expect(prepared.inputs).toHaveLength(2) + const firstPath = prepared.inputs[0] + const secondPath = prepared.inputs[1] + expect(firstPath).toBeDefined() + expect(secondPath).toBeDefined() + expect(firstPath).not.toBe(secondPath) + if (firstPath == null || secondPath == null) { + throw new Error('Expected prepared input paths') + } + + expect(await readFile(firstPath, 'utf8')).toBe('first-file') + expect(await readFile(secondPath, 'utf8')).toBe('second-file') + } finally { + await Promise.all(prepared.cleanup.map((cleanup) => cleanup())) + } + }) + it('supports base64 inputs for intent commands', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -232,6 +272,109 @@ describe('intent commands', () => { ) }) + it('rejects --watch URL inputs before downloading them', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + const downloadScope = nock('https://example.test').get('/file.pdf').reply(200, 'pdf') + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'preview', + 'generate', + '--watch', + '--input', + 'https://example.test/file.pdf', + '--out', + 'preview.png', + ]) + + expect(process.exitCode).toBe(1) + expect(createSpy).not.toHaveBeenCalled() + expect(downloadScope.isDone()).toBe(false) + }) + + it('accepts native boolean flags for generated intent options', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'optimize', + '--input', + 'input.jpg', + '--progressive', + '--out', + 'optimized.jpg', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['input.jpg'], + stepsData: { + [getIntentStepName(['image', 'optimize'])]: expect.objectContaining({ + robot: '/image/optimize', + use: ':original', + progressive: true, + }), + }, + }), + ) + }) + + it('rejects multi-input standard single-assembly runs with a file output before processing', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const tempDir = await createTempDir('transloadit-intent-single-assembly-') + const inputA = path.join(tempDir, 'a.jpg') + const inputB = path.join(tempDir, 'b.jpg') + await writeFile(inputA, 'a') + await writeFile(inputB, 'b') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'optimize', + '--single-assembly', + '--input', + inputA, + '--input', + inputB, + '--out', + path.join(tempDir, 'optimized.jpg'), + ]) + + expect(process.exitCode).toBe(1) + expect(createSpy).not.toHaveBeenCalled() + const loggedError = errorSpy.mock.calls.flatMap((call) => call.map(String)).join(' ') + expect(loggedError).toContain( + 'Output must be a directory when using --single-assembly with multiple inputs', + ) + }) + it('maps video encode-hls to the builtin template', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -763,7 +906,6 @@ describe('intent commands', () => { '--format', 'zip', '--gzip', - 'true', '--out', 'assets.zip', ]) diff --git a/packages/node/test/unit/input-files.test.ts b/packages/node/test/unit/input-files.test.ts index 01179a54..6eb6e1c5 100644 --- a/packages/node/test/unit/input-files.test.ts +++ b/packages/node/test/unit/input-files.test.ts @@ -1,9 +1,24 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' import { prepareInputFiles } from '../../src/inputFiles.ts' +const { lookupMock } = vi.hoisted(() => ({ + lookupMock: vi.fn(), +})) + +vi.mock('node:dns/promises', () => ({ + lookup: lookupMock, +})) + +afterEach(() => { + vi.restoreAllMocks() + lookupMock.mockReset() + nock.cleanAll() +}) + describe('prepareInputFiles', () => { it('splits files, uploads, and url imports', async () => { const base64 = Buffer.from('hello').toString('base64') @@ -93,4 +108,50 @@ describe('prepareInputFiles', () => { }), ).rejects.toThrow('URL downloads are limited') }) + + it('rejects hostnames that resolve to private IPs', async () => { + lookupMock.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) + const downloadScope = nock('http://rebind.test').get('/secret').reply(200, 'secret') + + await expect( + prepareInputFiles({ + inputFiles: [ + { + kind: 'url', + field: 'remote', + url: 'http://rebind.test/secret', + }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }), + ).rejects.toThrow('URL downloads are limited') + + expect(downloadScope.isDone()).toBe(false) + }) + + it('rejects redirects to private URL downloads', async () => { + lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) + const publicScope = nock('http://198.51.100.10') + .get('/public') + .reply(302, undefined, { Location: 'http://127.0.0.1/secret' }) + const privateScope = nock('http://127.0.0.1').get('/secret').reply(200, 'secret') + + await expect( + prepareInputFiles({ + inputFiles: [ + { + kind: 'url', + field: 'remote', + url: 'http://198.51.100.10/public', + }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }), + ).rejects.toThrow('URL downloads are limited') + + expect(publicScope.isDone()).toBe(true) + expect(privateScope.isDone()).toBe(false) + }) }) From a53a40fa0feb6a60568e95c3132a51e2fc6c3787 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 07:47:34 +0200 Subject: [PATCH 24/44] refactor(node): reduce generated intent boilerplate --- .../node/scripts/generate-intent-commands.ts | 87 +- packages/node/src/cli/commands/assemblies.ts | 1 - .../src/cli/commands/generated-intents.ts | 2670 ++++++++++------- packages/node/src/cli/intentRuntime.ts | 123 +- 4 files changed, 1647 insertions(+), 1234 deletions(-) diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 4296b2df..4effd90a 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -24,49 +24,43 @@ function formatUsageExamples(examples: Array<[string, string]>): string { .join('\n') } -function formatSchemaFields(fieldSpecs: GeneratedSchemaField[]): string { +function formatFieldDefinitionsName(spec: ResolvedIntentCommandSpec): string { + return `${spec.className[0]?.toLowerCase() ?? ''}${spec.className.slice(1)}Fields` +} + +function formatSchemaFields( + fieldSpecs: GeneratedSchemaField[], + spec: ResolvedIntentCommandSpec, +): string { return fieldSpecs .map((fieldSpec) => { - const requiredLine = fieldSpec.required ? '\n required: true,' : '' - const optionExpression = - fieldSpec.kind === 'boolean' - ? `Option.Boolean('${fieldSpec.optionFlags}', {` - : fieldSpec.kind === 'number' - ? `Option.String('${fieldSpec.optionFlags}', {\n description: ${formatDescription(fieldSpec.description)},${requiredLine}\n validator: t.isNumber(),\n })` - : `Option.String('${fieldSpec.optionFlags}', {` - - if (fieldSpec.kind === 'number') { - return ` ${fieldSpec.propertyName} = ${optionExpression}` - } - - return ` ${fieldSpec.propertyName} = ${optionExpression} - description: ${formatDescription(fieldSpec.description)},${requiredLine} - })` + return ` ${fieldSpec.propertyName} = createIntentOption(${formatFieldDefinitionsName(spec)}.${fieldSpec.propertyName})` }) .join('\n\n') } -function formatRawValues(fieldSpecs: GeneratedSchemaField[]): string { +function formatFieldDefinitions( + fieldSpecs: GeneratedSchemaField[], + spec: ResolvedIntentCommandSpec, +): string { if (fieldSpecs.length === 0) { - return '{}' + return '' } - return `{ -${fieldSpecs.map((fieldSpec) => ` ${JSON.stringify(fieldSpec.name)}: this.${fieldSpec.propertyName},`).join('\n')} - }` -} - -function formatFieldSpecsLiteral(fieldSpecs: GeneratedSchemaField[]): string { - if (fieldSpecs.length === 0) return '[]' - - return `[ + return `const ${formatFieldDefinitionsName(spec)} = { ${fieldSpecs - .map( - (fieldSpec) => - ` { name: ${JSON.stringify(fieldSpec.name)}, kind: ${JSON.stringify(fieldSpec.kind)} },`, - ) + .map((fieldSpec) => { + const requiredLine = fieldSpec.required ? '\n required: true,' : '' + return ` ${fieldSpec.propertyName}: { + name: ${JSON.stringify(fieldSpec.name)}, + kind: ${JSON.stringify(fieldSpec.kind)}, + propertyName: ${JSON.stringify(fieldSpec.propertyName)}, + optionFlags: ${JSON.stringify(fieldSpec.optionFlags)}, + description: ${formatDescription(fieldSpec.description)},${requiredLine} + },` + }) .join('\n')} - ]` +} as const` } function generateImports(specs: ResolvedIntentCommandSpec[]): string { @@ -112,12 +106,14 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` const outputLines = `\n outputDescription: ${JSON.stringify(spec.outputDescription)},\n outputRequired: ${JSON.stringify(spec.outputRequired)},` + const fieldsLine = + spec.fieldSpecs.length === 0 ? '[]' : `Object.values(${formatFieldDefinitionsName(spec)})` return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${inputPolicyLine}${outputMode}${outputLines} execution: { kind: 'single-step', schema: ${spec.schemaSpec?.importName}, - fieldSpecs: ${formatFieldSpecsLiteral(spec.fieldSpecs)}, + fields: ${fieldsLine}, fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 4).replace(/\n/g, '\n ')}, resultStepName: ${JSON.stringify(spec.execution.resultStepName)}, }, @@ -138,21 +134,16 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { } as const` } -function formatRawValuesMethod(fieldSpecs: GeneratedSchemaField[]): string { - return ` protected override getIntentRawValues(): Record { - return ${formatRawValues(fieldSpecs)} - }` -} - function generateClass(spec: ResolvedIntentCommandSpec): string { - const schemaFields = formatSchemaFields(spec.fieldSpecs) - const rawValuesMethod = formatRawValuesMethod(spec.fieldSpecs) + const schemaFields = formatSchemaFields(spec.fieldSpecs, spec) const baseClassName = getBaseClassName(spec) return ` class ${spec.className} extends ${baseClassName} { static override paths = ${JSON.stringify([spec.paths])} + static override intentDefinition = ${getCommandDefinitionName(spec)} + static override usage = Command.Usage({ category: 'Intent Commands', description: ${JSON.stringify(spec.description)}, @@ -162,16 +153,15 @@ ${formatUsageExamples(spec.examples)} ], }) - protected override getIntentDefinition() { - return ${getCommandDefinitionName(spec)} - } - -${schemaFields}${schemaFields ? '\n\n' : ''}${rawValuesMethod} +${schemaFields} } ` } function generateFile(specs: ResolvedIntentCommandSpec[]): string { + const fieldDefinitions = specs + .map((spec) => formatFieldDefinitions(spec.fieldSpecs, spec)) + .filter((definition) => definition.length > 0) const commandDefinitions = specs.map(formatIntentDefinition) const commandClasses = specs.map(generateClass) const commandNames = specs.map((spec) => spec.className) @@ -179,15 +169,16 @@ function generateFile(specs: ResolvedIntentCommandSpec[]): string { return `// DO NOT EDIT BY HAND. // Generated by \`packages/node/scripts/generate-intent-commands.ts\`. -import { Command, Option } from 'clipanion' -import * as t from 'typanion' +import { Command } from 'clipanion' ${generateImports(specs)} import { + createIntentOption, GeneratedBundledFileIntentCommand, GeneratedNoInputIntentCommand, GeneratedStandardFileIntentCommand, } from '../intentRuntime.ts' +${fieldDefinitions.join('\n\n')} ${commandDefinitions.join('\n\n')} ${commandClasses.join('\n')} export const intentCommands = [ diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 97404ac5..2a7cf155 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1411,7 +1411,6 @@ export async function create( outputPlan: OutputPlan | null, ): Promise { const inStream = inPath ? createInputUploadStream(inPath) : null - inStream?.on('error', () => {}) return await executeAssemblyLifecycle({ createOptions: createAssemblyOptions(inStream == null ? undefined : { in: inStream }), diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index 20e4081e..a57511ee 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -1,8 +1,7 @@ // DO NOT EDIT BY HAND. // Generated by `packages/node/scripts/generate-intent-commands.ts`. -import { Command, Option } from 'clipanion' -import * as t from 'typanion' +import { Command } from 'clipanion' import { robotAudioWaveformInstructionsSchema } from '../../alphalib/types/robots/audio-waveform.ts' import { robotDocumentAutorotateInstructionsSchema } from '../../alphalib/types/robots/document-autorotate.ts' @@ -19,11 +18,1320 @@ import { robotImageResizeInstructionsSchema } from '../../alphalib/types/robots/ import { robotTextSpeakInstructionsSchema } from '../../alphalib/types/robots/text-speak.ts' import { robotVideoThumbsInstructionsSchema } from '../../alphalib/types/robots/video-thumbs.ts' import { + createIntentOption, GeneratedBundledFileIntentCommand, GeneratedNoInputIntentCommand, GeneratedStandardFileIntentCommand, } from '../intentRuntime.ts' +const imageGenerateCommandFields = { + model: { + name: 'model', + kind: 'string', + propertyName: 'model', + optionFlags: '--model', + description: 'The AI model to use for image generation. Defaults to google/nano-banana.', + }, + prompt: { + name: 'prompt', + kind: 'string', + propertyName: 'prompt', + optionFlags: '--prompt', + description: 'The prompt describing the desired image content.', + required: true, + }, + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: 'Format of the generated image.', + }, + seed: { + name: 'seed', + kind: 'number', + propertyName: 'seed', + optionFlags: '--seed', + description: 'Seed for the random number generator.', + }, + aspectRatio: { + name: 'aspect_ratio', + kind: 'string', + propertyName: 'aspectRatio', + optionFlags: '--aspect-ratio', + description: 'Aspect ratio of the generated image.', + }, + height: { + name: 'height', + kind: 'number', + propertyName: 'height', + optionFlags: '--height', + description: 'Height of the generated image.', + }, + width: { + name: 'width', + kind: 'number', + propertyName: 'width', + optionFlags: '--width', + description: 'Width of the generated image.', + }, + style: { + name: 'style', + kind: 'string', + propertyName: 'style', + optionFlags: '--style', + description: 'Style of the generated image.', + }, + numOutputs: { + name: 'num_outputs', + kind: 'number', + propertyName: 'numOutputs', + optionFlags: '--num-outputs', + description: 'Number of image variants to generate.', + }, +} as const + +const previewGenerateCommandFields = { + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: + 'The output format for the generated thumbnail image. If a short video clip is generated using the `clip` strategy, its format is defined by `clip_format`.', + }, + width: { + name: 'width', + kind: 'number', + propertyName: 'width', + optionFlags: '--width', + description: 'Width of the thumbnail, in pixels.', + }, + height: { + name: 'height', + kind: 'number', + propertyName: 'height', + optionFlags: '--height', + description: 'Height of the thumbnail, in pixels.', + }, + resizeStrategy: { + name: 'resize_strategy', + kind: 'string', + propertyName: 'resizeStrategy', + optionFlags: '--resize-strategy', + description: + 'To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. This happens, for example, when the dimensions of a frame extracted from a video do not match the chosen `width` and `height` parameters.\n\nSee the list of available [resize strategies](/docs/topics/resize-strategies/) for more details.', + }, + background: { + name: 'background', + kind: 'string', + propertyName: 'background', + optionFlags: '--background', + description: + 'The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). The format is `#rrggbb[aa]` (red, green, blue, alpha). Use `#00000000` for a transparent padding.', + }, + strategy: { + name: 'strategy', + kind: 'json', + propertyName: 'strategy', + optionFlags: '--strategy', + description: + 'Definition of the thumbnail generation process per file category. The parameter must be an object whose keys can be one of the file categories: `audio`, `video`, `image`, `document`, `archive`, `webpage`, and `unknown`. The corresponding value is an array of strategies for the specific file category. See the above section for a list of all available strategies.\n\nFor each file, the Robot will attempt to use the first strategy to generate the thumbnail. If this process fails (e.g., because no artwork is available in a video file), the next strategy is attempted. This is repeated until either a thumbnail is generated or the list is exhausted. Selecting the `icon` strategy as the last entry provides a fallback mechanism to ensure that an appropriate strategy is always available.\n\nThe parameter defaults to the following definition:\n\n```json\n{\n "audio": ["artwork", "waveform", "icon"],\n "video": ["artwork", "frame", "icon"],\n "document": ["page", "icon"],\n "image": ["image", "icon"],\n "webpage": ["render", "icon"],\n "archive": ["icon"],\n "unknown": ["icon"]\n}\n```', + }, + artworkOuterColor: { + name: 'artwork_outer_color', + kind: 'string', + propertyName: 'artworkOuterColor', + optionFlags: '--artwork-outer-color', + description: "The color used in the outer parts of the artwork's gradient.", + }, + artworkCenterColor: { + name: 'artwork_center_color', + kind: 'string', + propertyName: 'artworkCenterColor', + optionFlags: '--artwork-center-color', + description: "The color used in the center of the artwork's gradient.", + }, + waveformCenterColor: { + name: 'waveform_center_color', + kind: 'string', + propertyName: 'waveformCenterColor', + optionFlags: '--waveform-center-color', + description: + "The color used in the center of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", + }, + waveformOuterColor: { + name: 'waveform_outer_color', + kind: 'string', + propertyName: 'waveformOuterColor', + optionFlags: '--waveform-outer-color', + description: + "The color used in the outer parts of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", + }, + waveformHeight: { + name: 'waveform_height', + kind: 'number', + propertyName: 'waveformHeight', + optionFlags: '--waveform-height', + description: + 'Height of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', + }, + waveformWidth: { + name: 'waveform_width', + kind: 'number', + propertyName: 'waveformWidth', + optionFlags: '--waveform-width', + description: + 'Width of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', + }, + iconStyle: { + name: 'icon_style', + kind: 'string', + propertyName: 'iconStyle', + optionFlags: '--icon-style', + description: + 'The style of the icon generated if the `icon` strategy is applied. The default style, `with-text`, includes an icon showing the file type and a text box below it, whose content can be controlled by the `icon_text_content` parameter and defaults to the file extension (e.g. MP4, JPEG). The `square` style only includes a square variant of the icon showing the file type. Below are exemplary previews generated for a text file utilizing the different styles:\n\n

`with-text` style:
\n![Image with text style]({{site.asset_cdn}}/assets/images/file-preview/icon-with-text.png)\n

`square` style:
\n![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png)', + }, + iconTextColor: { + name: 'icon_text_color', + kind: 'string', + propertyName: 'iconTextColor', + optionFlags: '--icon-text-color', + description: + 'The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied.', + }, + iconTextFont: { + name: 'icon_text_font', + kind: 'string', + propertyName: 'iconTextFont', + optionFlags: '--icon-text-font', + description: + 'The font family of the text used in the icon. Only used if the `icon` strategy is applied. [Here](/docs/supported-formats/fonts/) is a list of all supported fonts.', + }, + iconTextContent: { + name: 'icon_text_content', + kind: 'string', + propertyName: 'iconTextContent', + optionFlags: '--icon-text-content', + description: + 'The content of the text box in generated icons. Only used if the `icon_style` parameter is set to `with-text`. The default value, `extension`, adds the file extension (e.g. MP4, JPEG) to the icon. The value `none` can be used to render an empty text box, which is useful if no text should not be included in the raster image, but some place should be reserved in the image for later overlaying custom text over the image using HTML etc.', + }, + optimize: { + name: 'optimize', + kind: 'boolean', + propertyName: 'optimize', + optionFlags: '--optimize', + description: + "Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. If enabled, the images will be optimized using [🤖/image/optimize](/docs/robots/image-optimize/).", + }, + optimizePriority: { + name: 'optimize_priority', + kind: 'string', + propertyName: 'optimizePriority', + optionFlags: '--optimize-priority', + description: + 'Specifies whether conversion speed or compression ratio is prioritized when optimizing images. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-priority) for more details.', + }, + optimizeProgressive: { + name: 'optimize_progressive', + kind: 'boolean', + propertyName: 'optimizeProgressive', + optionFlags: '--optimize-progressive', + description: + 'Specifies whether images should be interlaced, which makes the result image load progressively in browsers. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-progressive) for more details.', + }, + clipFormat: { + name: 'clip_format', + kind: 'string', + propertyName: 'clipFormat', + optionFlags: '--clip-format', + description: + 'The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied.\n\nPlease consult the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types) for detailed information about the image formats and their characteristics. GIF enjoys the broadest support in software, but only supports a limit color palette. APNG supports a variety of color depths, but its lossless compression produces large images for videos. AVIF is a modern image format that offers great compression, but proper support for animations is still lacking in some browsers. WebP on the other hand, enjoys broad support while offering a great balance between small file sizes and good visual quality, making it the default clip format.', + }, + clipOffset: { + name: 'clip_offset', + kind: 'number', + propertyName: 'clipOffset', + optionFlags: '--clip-offset', + description: + 'The start position in seconds of where the clip is cut. Only used if the `clip` strategy for video files is applied. Be aware that for larger video only the first few MBs of the file may be imported to improve speed. Larger offsets may seek to a position outside of the imported part and thus fail to generate a clip.', + }, + clipDuration: { + name: 'clip_duration', + kind: 'number', + propertyName: 'clipDuration', + optionFlags: '--clip-duration', + description: + 'The duration in seconds of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a longer clip duration also results in a larger file size, which might be undesirable for previews.', + }, + clipFramerate: { + name: 'clip_framerate', + kind: 'number', + propertyName: 'clipFramerate', + optionFlags: '--clip-framerate', + description: + 'The framerate of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a higher framerate appears smoother but also results in a larger file size, which might be undesirable for previews.', + }, + clipLoop: { + name: 'clip_loop', + kind: 'boolean', + propertyName: 'clipLoop', + optionFlags: '--clip-loop', + description: + 'Specifies whether the generated animated image should loop forever (`true`) or stop after playing the animation once (`false`). Only used if the `clip` strategy for video files is applied.', + }, +} as const + +const imageRemoveBackgroundCommandFields = { + select: { + name: 'select', + kind: 'string', + propertyName: 'select', + optionFlags: '--select', + description: 'Region to select and keep in the image. The other region is removed.', + }, + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: 'Format of the generated image.', + }, + provider: { + name: 'provider', + kind: 'string', + propertyName: 'provider', + optionFlags: '--provider', + description: 'Provider to use for removing the background.', + }, + model: { + name: 'model', + kind: 'string', + propertyName: 'model', + optionFlags: '--model', + description: + 'Provider-specific model to use for removing the background. Mostly intended for testing and evaluation.', + }, +} as const + +const imageOptimizeCommandFields = { + priority: { + name: 'priority', + kind: 'string', + propertyName: 'priority', + optionFlags: '--priority', + description: + 'Provides different algorithms for better or worse compression for your images, but that run slower or faster. The value `"conversion-speed"` will result in an average compression ratio of 18%. `"compression-ratio"` will result in an average compression ratio of 31%.', + }, + progressive: { + name: 'progressive', + kind: 'boolean', + propertyName: 'progressive', + optionFlags: '--progressive', + description: + 'Interlaces the image if set to `true`, which makes the result image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the image which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a loss of about 10% of the file size reduction.', + }, + preserveMetaData: { + name: 'preserve_meta_data', + kind: 'boolean', + propertyName: 'preserveMetaData', + optionFlags: '--preserve-meta-data', + description: + "Specifies if the image's metadata should be preserved during the optimization, or not. If it is not preserved, the file size is even further reduced. But be aware that this could strip a photographer's copyright information, which for obvious reasons can be frowned upon.", + }, + fixBreakingImages: { + name: 'fix_breaking_images', + kind: 'boolean', + propertyName: 'fixBreakingImages', + optionFlags: '--fix-breaking-images', + description: + 'If set to `true` this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies. This can sometimes result in a larger file size, though.', + }, +} as const + +const imageResizeCommandFields = { + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: + 'The output format for the modified image.\n\nSome of the most important available formats are `"jpg"`, `"png"`, `"gif"`, and `"tiff"`. For a complete lists of all formats that we can write to please check [our supported image formats list](/docs/supported-formats/image-formats/).\n\nIf `null` (default), then the input image\'s format will be used as the output format.\n\nIf you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead.', + }, + width: { + name: 'width', + kind: 'number', + propertyName: 'width', + optionFlags: '--width', + description: + 'Width of the result in pixels. If not specified, will default to the width of the original.', + }, + height: { + name: 'height', + kind: 'number', + propertyName: 'height', + optionFlags: '--height', + description: + 'Height of the new image, in pixels. If not specified, will default to the height of the input image.', + }, + resizeStrategy: { + name: 'resize_strategy', + kind: 'string', + propertyName: 'resizeStrategy', + optionFlags: '--resize-strategy', + description: 'See the list of available [resize strategies](/docs/topics/resize-strategies/).', + }, + zoom: { + name: 'zoom', + kind: 'boolean', + propertyName: 'zoom', + optionFlags: '--zoom', + description: + 'If this is set to `false`, smaller images will not be stretched to the desired width and height. For details about the impact of zooming for your preferred resize strategy, see the list of available [resize strategies](/docs/topics/resize-strategies/).', + }, + crop: { + name: 'crop', + kind: 'auto', + propertyName: 'crop', + optionFlags: '--crop', + description: + 'Specify an object containing coordinates for the top left and bottom right corners of the rectangle to be cropped from the original image(s). The coordinate system is rooted in the top left corner of the image. Values can be integers for absolute pixel values or strings for percentage based values.\n\nFor example:\n\n```json\n{\n "x1": 80,\n "y1": 100,\n "x2": "60%",\n "y2": "80%"\n}\n```\n\nThis will crop the area from `(80, 100)` to `(600, 800)` from a 1000×1000 pixels image, which is a square whose width is 520px and height is 700px. If `crop` is set, the width and height parameters are ignored, and the `resize_strategy` is set to `crop` automatically.\n\nYou can also use a JSON string of such an object with coordinates in similar fashion:\n\n```json\n"{\\"x1\\": , \\"y1\\": , \\"x2\\": , \\"y2\\": }"\n```\n\nTo crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/).', + }, + gravity: { + name: 'gravity', + kind: 'string', + propertyName: 'gravity', + optionFlags: '--gravity', + description: + 'The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined.', + }, + strip: { + name: 'strip', + kind: 'boolean', + propertyName: 'strip', + optionFlags: '--strip', + description: + 'Strips all metadata from the image. This is useful to keep thumbnails as small as possible.', + }, + alpha: { + name: 'alpha', + kind: 'string', + propertyName: 'alpha', + optionFlags: '--alpha', + description: 'Gives control of the alpha/matte channel of an image.', + }, + preclipAlpha: { + name: 'preclip_alpha', + kind: 'string', + propertyName: 'preclipAlpha', + optionFlags: '--preclip-alpha', + description: + 'Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`.', + }, + flatten: { + name: 'flatten', + kind: 'boolean', + propertyName: 'flatten', + optionFlags: '--flatten', + description: + 'Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#layers).\n\nTo preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter.', + }, + correctGamma: { + name: 'correct_gamma', + kind: 'boolean', + propertyName: 'correctGamma', + optionFlags: '--correct-gamma', + description: + 'Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html).', + }, + quality: { + name: 'quality', + kind: 'number', + propertyName: 'quality', + optionFlags: '--quality', + description: + 'Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', + }, + adaptiveFiltering: { + name: 'adaptive_filtering', + kind: 'boolean', + propertyName: 'adaptiveFiltering', + optionFlags: '--adaptive-filtering', + description: + 'Controls the image compression for PNG images. Setting to `true` results in smaller file size, while increasing processing time. It is encouraged to keep this option disabled.', + }, + background: { + name: 'background', + kind: 'string', + propertyName: 'background', + optionFlags: '--background', + description: + 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (used for the `pad` resize strategy).\n\n**Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`.', + }, + frame: { + name: 'frame', + kind: 'number', + propertyName: 'frame', + optionFlags: '--frame', + description: + 'Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. Specify `1` to use the first frame, `2` to use the second, and so on. `null` means all frames.', + }, + colorspace: { + name: 'colorspace', + kind: 'string', + propertyName: 'colorspace', + optionFlags: '--colorspace', + description: + 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace). Please note that if you were using `"RGB"`, we recommend using `"sRGB"` instead as of 2014-02-04. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might have to use this parameter in combination with `type: "TrueColor"`.', + }, + type: { + name: 'type', + kind: 'string', + propertyName: 'type', + optionFlags: '--type', + description: + 'Sets the image color type. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#type). If you\'re using `colorspace`, ImageMagick might try to find the most efficient based on the color of an image, and default to e.g. `"Gray"`. To force colors, you could e.g. set this parameter to `"TrueColor"`', + }, + sepia: { + name: 'sepia', + kind: 'number', + propertyName: 'sepia', + optionFlags: '--sepia', + description: 'Applies a sepia tone effect in percent.', + }, + rotation: { + name: 'rotation', + kind: 'auto', + propertyName: 'rotation', + optionFlags: '--rotation', + description: + 'Determines whether the image should be rotated. Use any number to specify the rotation angle in degrees (e.g., `90`, `180`, `270`, `360`, or precise values like `2.9`). Use the value `true` or `"auto"` to auto-rotate images that are rotated incorrectly or depend on EXIF rotation settings. Otherwise, use `false` to disable auto-fixing altogether.', + }, + compress: { + name: 'compress', + kind: 'string', + propertyName: 'compress', + optionFlags: '--compress', + description: + 'Specifies pixel compression for when the image is written. Compression is disabled by default.\n\nPlease also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', + }, + blur: { + name: 'blur', + kind: 'string', + propertyName: 'blur', + optionFlags: '--blur', + description: + 'Specifies gaussian blur, using a value with the form `{radius}x{sigma}`. The radius value specifies the size of area the operator should look at when spreading pixels, and should typically be either `"0"` or at least two times the sigma value. The sigma value is an approximation of how many pixels the image is "spread"; think of it as the size of the brush used to blur the image. This number is a floating point value, enabling small values like `"0.5"` to be used.', + }, + blurRegions: { + name: 'blur_regions', + kind: 'json', + propertyName: 'blurRegions', + optionFlags: '--blur-regions', + description: + 'Specifies an array of ellipse objects that should be blurred on the image. Each object has the following keys: `x`, `y`, `width`, `height`. If `blur_regions` has a value, then the `blur` parameter is used as the strength of the blur for each region.', + }, + brightness: { + name: 'brightness', + kind: 'number', + propertyName: 'brightness', + optionFlags: '--brightness', + description: + 'Increases or decreases the brightness of the image by using a multiplier. For example `1.5` would increase the brightness by 50%, and `0.75` would decrease the brightness by 25%.', + }, + saturation: { + name: 'saturation', + kind: 'number', + propertyName: 'saturation', + optionFlags: '--saturation', + description: + 'Increases or decreases the saturation of the image by using a multiplier. For example `1.5` would increase the saturation by 50%, and `0.75` would decrease the saturation by 25%.', + }, + hue: { + name: 'hue', + kind: 'number', + propertyName: 'hue', + optionFlags: '--hue', + description: + 'Changes the hue by rotating the color of the image. The value `100` would produce no change whereas `0` and `200` will negate the colors in the image.', + }, + contrast: { + name: 'contrast', + kind: 'number', + propertyName: 'contrast', + optionFlags: '--contrast', + description: + 'Adjusts the contrast of the image. A value of `1` produces no change. Values below `1` decrease contrast (with `0` being minimum contrast), and values above `1` increase contrast (with `2` being maximum contrast). This works like the `brightness` parameter.', + }, + watermarkUrl: { + name: 'watermark_url', + kind: 'string', + propertyName: 'watermarkUrl', + optionFlags: '--watermark-url', + description: + 'A URL indicating a PNG image to be overlaid above this image. Please note that you can also [supply the watermark via another Assembly Step](/docs/topics/use-parameter/#supplying-the-watermark-via-an-assembly-step). With watermarking you can add an image onto another image. This is usually used for logos.', + }, + watermarkPosition: { + name: 'watermark_position', + kind: 'auto', + propertyName: 'watermarkPosition', + optionFlags: '--watermark-position', + description: + 'The position at which the watermark is placed. The available options are `"center"`, `"top"`, `"bottom"`, `"left"`, and `"right"`. You can also combine options, such as `"bottom-right"`.\n\nAn array of possible values can also be specified, in which case one value will be selected at random, such as `[ "center", "left", "bottom-left", "bottom-right" ]`.\n\nThis setting puts the watermark in the specified corner. To use a specific pixel offset for the watermark, you will need to add the padding to the image itself.', + }, + watermarkXOffset: { + name: 'watermark_x_offset', + kind: 'number', + propertyName: 'watermarkXOffset', + optionFlags: '--watermark-x-offset', + description: + "The x-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", + }, + watermarkYOffset: { + name: 'watermark_y_offset', + kind: 'number', + propertyName: 'watermarkYOffset', + optionFlags: '--watermark-y-offset', + description: + "The y-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", + }, + watermarkSize: { + name: 'watermark_size', + kind: 'string', + propertyName: 'watermarkSize', + optionFlags: '--watermark-size', + description: + 'The size of the watermark, as a percentage.\n\nFor example, a value of `"50%"` means that size of the watermark will be 50% of the size of image on which it is placed. The exact sizing depends on `watermark_resize_strategy`, too.', + }, + watermarkResizeStrategy: { + name: 'watermark_resize_strategy', + kind: 'string', + propertyName: 'watermarkResizeStrategy', + optionFlags: '--watermark-resize-strategy', + description: + 'Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`.\n\nTo explain how the resize strategies work, let\'s assume our target image size is 800×800 pixels and our watermark image is 400×300 pixels. Let\'s also assume, the `watermark_size` parameter is set to `"25%"`.\n\nFor the `"fit"` resize strategy, the watermark is scaled so that the longer side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the width is the longer side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 200×150 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 400×300 pixels (so just left at its original size).\n\nFor the `"min_fit"` resize strategy, the watermark is scaled so that the shorter side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the height is the shorter side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 267×200 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 533×400 pixels (so larger than its original size).\n\nFor the `"stretch"` resize strategy, the watermark is stretched (meaning, it is resized without keeping its aspect ratio in mind) so that both sides take up 25% of the corresponding image side. Since our image is 800×800 pixels, for a watermark size of 25% the watermark would be resized to 200×200 pixels. Its height would appear stretched, because keeping the aspect ratio in mind it would be resized to 200×150 pixels instead.\n\nFor the `"area"` resize strategy, the watermark is resized (keeping its aspect ratio in check) so that it covers `"xx%"` of the image\'s surface area. The value from `watermark_size` is used for the percentage area size.', + }, + watermarkOpacity: { + name: 'watermark_opacity', + kind: 'number', + propertyName: 'watermarkOpacity', + optionFlags: '--watermark-opacity', + description: + 'The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque.\n\nFor example, a value of `0.5` means the watermark will be 50% transparent, allowing the underlying image to show through. This is useful for subtle branding or when you want the watermark to be less obtrusive.', + }, + watermarkRepeatX: { + name: 'watermark_repeat_x', + kind: 'boolean', + propertyName: 'watermarkRepeatX', + optionFlags: '--watermark-repeat-x', + description: + 'When set to `true`, the watermark will be repeated horizontally across the entire width of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark.', + }, + watermarkRepeatY: { + name: 'watermark_repeat_y', + kind: 'boolean', + propertyName: 'watermarkRepeatY', + optionFlags: '--watermark-repeat-y', + description: + 'When set to `true`, the watermark will be repeated vertically across the entire height of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image. Can be combined with `watermark_repeat_x` to tile in both directions.', + }, + text: { + name: 'text', + kind: 'json', + propertyName: 'text', + optionFlags: '--text', + description: + 'Text overlays to be applied to the image. Can be either a single text object or an array of text objects. Each text object contains text rules. The following text parameters are intended to be used as properties for your text overlays. Here is an example:\n\n```json\n"watermarked": {\n "use": "resized",\n "robot": "/image/resize",\n "text": [\n {\n "text": "© 2018 Transloadit.com",\n "size": 12,\n "font": "Ubuntu",\n "color": "#eeeeee",\n "valign": "bottom",\n "align": "right",\n "x_offset": 16,\n "y_offset": -10\n }\n ]\n}\n```', + }, + progressive: { + name: 'progressive', + kind: 'boolean', + propertyName: 'progressive', + optionFlags: '--progressive', + description: + 'Interlaces the image if set to `true`, which makes the image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the images which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a cost of a file size increase by around 10%.', + }, + transparent: { + name: 'transparent', + kind: 'string', + propertyName: 'transparent', + optionFlags: '--transparent', + description: 'Make this color transparent within the image. Example: `"255,255,255"`.', + }, + trimWhitespace: { + name: 'trim_whitespace', + kind: 'boolean', + propertyName: 'trimWhitespace', + optionFlags: '--trim-whitespace', + description: + 'This determines if additional whitespace around the image should first be trimmed away. If you set this to `true` this parameter removes any edges that are exactly the same color as the corner pixels.', + }, + clip: { + name: 'clip', + kind: 'auto', + propertyName: 'clip', + optionFlags: '--clip', + description: + 'Apply the clipping path to other operations in the resize job, if one is present. If set to `true`, it will automatically take the first clipping path. If set to a String it finds a clipping path by that name.', + }, + negate: { + name: 'negate', + kind: 'boolean', + propertyName: 'negate', + optionFlags: '--negate', + description: + 'Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping.', + }, + density: { + name: 'density', + kind: 'string', + propertyName: 'density', + optionFlags: '--density', + description: + 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image is unsharp, please try increasing density.', + }, + monochrome: { + name: 'monochrome', + kind: 'boolean', + propertyName: 'monochrome', + optionFlags: '--monochrome', + description: + 'Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel.', + }, + shave: { + name: 'shave', + kind: 'auto', + propertyName: 'shave', + optionFlags: '--shave', + description: + 'Shave pixels from the image edges. The value should be in the format `width` or `width`x`height` to specify the number of pixels to remove from each side.', + }, +} as const + +const documentConvertCommandFields = { + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: 'The desired format for document conversion.', + required: true, + }, + markdownFormat: { + name: 'markdown_format', + kind: 'string', + propertyName: 'markdownFormat', + optionFlags: '--markdown-format', + description: + 'Markdown can be represented in several [variants](https://www.iana.org/assignments/markdown-variants/markdown-variants.xhtml), so when using this Robot to transform Markdown into HTML please specify which revision is being used.', + }, + markdownTheme: { + name: 'markdown_theme', + kind: 'string', + propertyName: 'markdownTheme', + optionFlags: '--markdown-theme', + description: + 'This parameter overhauls your Markdown files styling based on several canned presets.', + }, + pdfMargin: { + name: 'pdf_margin', + kind: 'string', + propertyName: 'pdfMargin', + optionFlags: '--pdf-margin', + description: + 'PDF Paper margins, separated by `,` and with units.\n\nWe support the following unit values: `px`, `in`, `cm`, `mm`.\n\nCurrently this parameter is only supported when converting from `html`.', + }, + pdfPrintBackground: { + name: 'pdf_print_background', + kind: 'boolean', + propertyName: 'pdfPrintBackground', + optionFlags: '--pdf-print-background', + description: + 'Print PDF background graphics.\n\nCurrently this parameter is only supported when converting from `html`.', + }, + pdfFormat: { + name: 'pdf_format', + kind: 'string', + propertyName: 'pdfFormat', + optionFlags: '--pdf-format', + description: + 'PDF paper format.\n\nCurrently this parameter is only supported when converting from `html`.', + }, + pdfDisplayHeaderFooter: { + name: 'pdf_display_header_footer', + kind: 'boolean', + propertyName: 'pdfDisplayHeaderFooter', + optionFlags: '--pdf-display-header-footer', + description: + 'Display PDF header and footer.\n\nCurrently this parameter is only supported when converting from `html`.', + }, + pdfHeaderTemplate: { + name: 'pdf_header_template', + kind: 'string', + propertyName: 'pdfHeaderTemplate', + optionFlags: '--pdf-header-template', + description: + 'HTML template for the PDF print header.\n\nShould be valid HTML markup with following classes used to inject printing values into them:\n- `date` formatted print date\n- `title` document title\n- `url` document location\n- `pageNumber` current page number\n- `totalPages` total pages in the document\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number at the top of a page you\'d use the following HTML for the header template:\n\n```html\n
\n```', + }, + pdfFooterTemplate: { + name: 'pdf_footer_template', + kind: 'string', + propertyName: 'pdfFooterTemplate', + optionFlags: '--pdf-footer-template', + description: + 'HTML template for the PDF print footer.\n\nShould use the same format as the `pdf_header_template`.\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number in the footer you\'d use the following HTML for the footer template:\n\n```html\n
\n```', + }, +} as const + +const documentOptimizeCommandFields = { + preset: { + name: 'preset', + kind: 'string', + propertyName: 'preset', + optionFlags: '--preset', + description: + 'The quality preset to use for optimization. Each preset provides a different balance between file size and quality:\n\n- `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI.\n- `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI.\n- `printer` - High quality suitable for printing. Images are kept at 300 DPI.\n- `prepress` - Highest quality for professional printing. Minimal compression applied.', + }, + imageDpi: { + name: 'image_dpi', + kind: 'number', + propertyName: 'imageDpi', + optionFlags: '--image-dpi', + description: + 'Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset.\n\nHigher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed.\n\nCommon values:\n- 72 - Screen viewing\n- 150 - eBooks and general documents\n- 300 - Print quality\n- 600 - High-quality print', + }, + compressFonts: { + name: 'compress_fonts', + kind: 'boolean', + propertyName: 'compressFonts', + optionFlags: '--compress-fonts', + description: + 'Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size.', + }, + subsetFonts: { + name: 'subset_fonts', + kind: 'boolean', + propertyName: 'subsetFonts', + optionFlags: '--subset-fonts', + description: + "Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. This can significantly reduce file size for documents that only use a small portion of a font's character set.", + }, + removeMetadata: { + name: 'remove_metadata', + kind: 'boolean', + propertyName: 'removeMetadata', + optionFlags: '--remove-metadata', + description: + 'Whether to strip document metadata (title, author, keywords, etc.) from the PDF. This can provide a small reduction in file size and may be useful for privacy.', + }, + linearize: { + name: 'linearize', + kind: 'boolean', + propertyName: 'linearize', + optionFlags: '--linearize', + description: + 'Whether to linearize (optimize for Fast Web View) the output PDF. Linearized PDFs can begin displaying in a browser before they are fully downloaded, improving the user experience for web delivery.', + }, + compatibility: { + name: 'compatibility', + kind: 'string', + propertyName: 'compatibility', + optionFlags: '--compatibility', + description: + 'The PDF version compatibility level. Lower versions have broader compatibility but fewer features. Higher versions support more advanced features but may not open in older PDF readers.\n\n- `1.4` - Acrobat 5 compatibility, most widely supported\n- `1.5` - Acrobat 6 compatibility\n- `1.6` - Acrobat 7 compatibility\n- `1.7` - Acrobat 8+ compatibility (default)\n- `2.0` - PDF 2.0 standard', + }, +} as const + +const documentThumbsCommandFields = { + page: { + name: 'page', + kind: 'number', + propertyName: 'page', + optionFlags: '--page', + description: + 'The PDF page that you want to convert to an image. By default the value is `null` which means that all pages will be converted into images.', + }, + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: + 'The format of the extracted image(s).\n\nIf you specify the value `"gif"`, then an animated gif cycling through all pages is created. Please check out [this demo](/demos/document-processing/convert-all-pages-of-a-document-into-an-animated-gif/) to learn more about this.', + }, + delay: { + name: 'delay', + kind: 'number', + propertyName: 'delay', + optionFlags: '--delay', + description: + 'If your output format is `"gif"` then this parameter sets the number of 100th seconds to pass before the next frame is shown in the animation. Set this to `100` for example to allow 1 second to pass between the frames of the animated gif.\n\nIf your output format is not `"gif"`, then this parameter does not have any effect.', + }, + width: { + name: 'width', + kind: 'number', + propertyName: 'width', + optionFlags: '--width', + description: + 'Width of the new image, in pixels. If not specified, will default to the width of the input image', + }, + height: { + name: 'height', + kind: 'number', + propertyName: 'height', + optionFlags: '--height', + description: + 'Height of the new image, in pixels. If not specified, will default to the height of the input image', + }, + resizeStrategy: { + name: 'resize_strategy', + kind: 'string', + propertyName: 'resizeStrategy', + optionFlags: '--resize-strategy', + description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', + }, + background: { + name: 'background', + kind: 'string', + propertyName: 'background', + optionFlags: '--background', + description: + 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (only used for the pad resize strategy).\n\nBy default, the background of transparent images is changed to white. For details about how to preserve transparency across all image types, see [this demo](/demos/image-manipulation/properly-preserve-transparency-across-all-image-types/).', + }, + alpha: { + name: 'alpha', + kind: 'string', + propertyName: 'alpha', + optionFlags: '--alpha', + description: + 'Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency.\n\nFor a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha).', + }, + density: { + name: 'density', + kind: 'string', + propertyName: 'density', + optionFlags: '--density', + description: + 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image has a low resolution, please try using the density parameter to resolve that.', + }, + antialiasing: { + name: 'antialiasing', + kind: 'boolean', + propertyName: 'antialiasing', + optionFlags: '--antialiasing', + description: + 'Controls whether or not antialiasing is used to remove jagged edges from text or images in a document.', + }, + colorspace: { + name: 'colorspace', + kind: 'string', + propertyName: 'colorspace', + optionFlags: '--colorspace', + description: + 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace).\n\nPlease note that if you were using `"RGB"`, we recommend using `"sRGB"`. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might then have to use this parameter.', + }, + trimWhitespace: { + name: 'trim_whitespace', + kind: 'boolean', + propertyName: 'trimWhitespace', + optionFlags: '--trim-whitespace', + description: + "This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. If you set this to `true` only the real PDF page contents will be shown in the image.\n\nIf you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`.", + }, + pdfUseCropbox: { + name: 'pdf_use_cropbox', + kind: 'boolean', + propertyName: 'pdfUseCropbox', + optionFlags: '--pdf-use-cropbox', + description: + "Some PDF documents lie about their dimensions. For instance they'll say they are landscape, but when opened in decent Desktop readers, it's really in portrait mode. This can happen if the document has a cropbox defined. When this option is enabled (by default), the cropbox is leading in determining the dimensions of the resulting thumbnails.", + }, + turbo: { + name: 'turbo', + kind: 'boolean', + propertyName: 'turbo', + optionFlags: '--turbo', + description: + "If you set this to `false`, the robot will not emit files as they become available. This is useful if you are only interested in the final result and not in the intermediate steps.\n\nAlso, extracted pages will be resized a lot faster as they are sent off to other machines for the resizing. This is especially useful for large documents with many pages to get up to 20 times faster processing.\n\nTurbo Mode increases pricing, though, in that the input document's file size is added for every extracted page. There are no performance benefits nor increased charges for single-page documents.", + }, +} as const + +const audioWaveformCommandFields = { + ffmpeg: { + name: 'ffmpeg', + kind: 'json', + propertyName: 'ffmpeg', + optionFlags: '--ffmpeg', + description: + 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', + }, + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: + 'The format of the result file. Can be `"image"` or `"json"`. If `"image"` is supplied, a PNG image will be created, otherwise a JSON file.', + }, + width: { + name: 'width', + kind: 'number', + propertyName: 'width', + optionFlags: '--width', + description: 'The width of the resulting image if the format `"image"` was selected.', + }, + height: { + name: 'height', + kind: 'number', + propertyName: 'height', + optionFlags: '--height', + description: 'The height of the resulting image if the format `"image"` was selected.', + }, + antialiasing: { + name: 'antialiasing', + kind: 'auto', + propertyName: 'antialiasing', + optionFlags: '--antialiasing', + description: + 'Either a value of `0` or `1`, or `true`/`false`, corresponding to if you want to enable antialiasing to achieve smoother edges in the waveform graph or not.', + }, + backgroundColor: { + name: 'background_color', + kind: 'string', + propertyName: 'backgroundColor', + optionFlags: '--background-color', + description: + 'The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected.', + }, + centerColor: { + name: 'center_color', + kind: 'string', + propertyName: 'centerColor', + optionFlags: '--center-color', + description: + 'The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', + }, + outerColor: { + name: 'outer_color', + kind: 'string', + propertyName: 'outerColor', + optionFlags: '--outer-color', + description: + 'The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', + }, + style: { + name: 'style', + kind: 'string', + propertyName: 'style', + optionFlags: '--style', + description: + 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', + }, + splitChannels: { + name: 'split_channels', + kind: 'boolean', + propertyName: 'splitChannels', + optionFlags: '--split-channels', + description: + 'Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel.', + }, + zoom: { + name: 'zoom', + kind: 'number', + propertyName: 'zoom', + optionFlags: '--zoom', + description: + 'Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`.', + }, + pixelsPerSecond: { + name: 'pixels_per_second', + kind: 'number', + propertyName: 'pixelsPerSecond', + optionFlags: '--pixels-per-second', + description: + 'Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`.', + }, + bits: { + name: 'bits', + kind: 'number', + propertyName: 'bits', + optionFlags: '--bits', + description: 'Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16.', + }, + start: { + name: 'start', + kind: 'number', + propertyName: 'start', + optionFlags: '--start', + description: 'Available when style is `"v1"`. Start time in seconds.', + }, + end: { + name: 'end', + kind: 'number', + propertyName: 'end', + optionFlags: '--end', + description: 'Available when style is `"v1"`. End time in seconds (0 means end of audio).', + }, + colors: { + name: 'colors', + kind: 'string', + propertyName: 'colors', + optionFlags: '--colors', + description: + 'Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity".', + }, + borderColor: { + name: 'border_color', + kind: 'string', + propertyName: 'borderColor', + optionFlags: '--border-color', + description: 'Available when style is `"v1"`. Border color in "rrggbbaa" format.', + }, + waveformStyle: { + name: 'waveform_style', + kind: 'string', + propertyName: 'waveformStyle', + optionFlags: '--waveform-style', + description: 'Available when style is `"v1"`. Waveform style. Can be "normal" or "bars".', + }, + barWidth: { + name: 'bar_width', + kind: 'number', + propertyName: 'barWidth', + optionFlags: '--bar-width', + description: + 'Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars".', + }, + barGap: { + name: 'bar_gap', + kind: 'number', + propertyName: 'barGap', + optionFlags: '--bar-gap', + description: + 'Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars".', + }, + barStyle: { + name: 'bar_style', + kind: 'string', + propertyName: 'barStyle', + optionFlags: '--bar-style', + description: 'Available when style is `"v1"`. Bar style when waveform_style is "bars".', + }, + axisLabelColor: { + name: 'axis_label_color', + kind: 'string', + propertyName: 'axisLabelColor', + optionFlags: '--axis-label-color', + description: 'Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format.', + }, + noAxisLabels: { + name: 'no_axis_labels', + kind: 'boolean', + propertyName: 'noAxisLabels', + optionFlags: '--no-axis-labels', + description: + 'Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels.', + }, + withAxisLabels: { + name: 'with_axis_labels', + kind: 'boolean', + propertyName: 'withAxisLabels', + optionFlags: '--with-axis-labels', + description: + 'Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels.', + }, + amplitudeScale: { + name: 'amplitude_scale', + kind: 'number', + propertyName: 'amplitudeScale', + optionFlags: '--amplitude-scale', + description: 'Available when style is `"v1"`. Amplitude scale factor.', + }, + compression: { + name: 'compression', + kind: 'number', + propertyName: 'compression', + optionFlags: '--compression', + description: + 'Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image".', + }, +} as const + +const textSpeakCommandFields = { + prompt: { + name: 'prompt', + kind: 'string', + propertyName: 'prompt', + optionFlags: '--prompt', + description: + 'Which text to speak. You can also set this to `null` and supply an input text file.', + }, + provider: { + name: 'provider', + kind: 'string', + propertyName: 'provider', + optionFlags: '--provider', + description: + 'Which AI provider to leverage.\n\nTransloadit outsources this task and abstracts the interface so you can expect the same data structures, but different latencies and information being returned. Different cloud vendors have different areas they shine in, and we recommend to try out and see what yields the best results for your use case.', + required: true, + }, + targetLanguage: { + name: 'target_language', + kind: 'string', + propertyName: 'targetLanguage', + optionFlags: '--target-language', + description: + 'The written language of the document. This will also be the language of the spoken text.\n\nThe language should be specified in the [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) format, such as `"en-GB"`, `"de-DE"` or `"fr-FR"`. Please consult the list of supported languages and voices.', + }, + voice: { + name: 'voice', + kind: 'string', + propertyName: 'voice', + optionFlags: '--voice', + description: + 'The gender to be used for voice synthesis. Please consult the list of supported languages and voices.', + }, + ssml: { + name: 'ssml', + kind: 'boolean', + propertyName: 'ssml', + optionFlags: '--ssml', + description: + 'Supply [Speech Synthesis Markup Language](https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language) instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations.\n\nPlease see the supported syntaxes for [AWS](https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html) and [GCP](https://cloud.google.com/text-to-speech/docs/ssml).', + }, +} as const + +const videoThumbsCommandFields = { + ffmpeg: { + name: 'ffmpeg', + kind: 'json', + propertyName: 'ffmpeg', + optionFlags: '--ffmpeg', + description: + 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', + }, + count: { + name: 'count', + kind: 'number', + propertyName: 'count', + optionFlags: '--count', + description: + 'The number of thumbnails to be extracted. As some videos have incorrect durations, the actual number of thumbnails generated may be less in rare cases. The maximum number of thumbnails we currently allow is 999.\n\nThe thumbnails are taken at regular intervals, determined by dividing the video duration by the count. For example, a count of 3 will produce thumbnails at 25%, 50% and 75% through the video.\n\nTo extract thumbnails for specific timestamps, use the `offsets` parameter.', + }, + offsets: { + name: 'offsets', + kind: 'json', + propertyName: 'offsets', + optionFlags: '--offsets', + description: + 'An array of offsets representing seconds of the file duration, such as `[ 2, 45, 120 ]`. Millisecond durations of a file can also be used by using decimal place values. For example, an offset from 1250 milliseconds would be represented with `1.25`. Offsets can also be percentage values such as `[ "2%", "50%", "75%" ]`.\n\nThis option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored.', + }, + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: + 'The format of the extracted thumbnail. Supported values are `"jpg"`, `"jpeg"` and `"png"`. Even if you specify the format to be `"jpeg"` the resulting thumbnails will have a `"jpg"` file extension.', + }, + width: { + name: 'width', + kind: 'number', + propertyName: 'width', + optionFlags: '--width', + description: + 'The width of the thumbnail, in pixels. Defaults to the original width of the video.', + }, + height: { + name: 'height', + kind: 'number', + propertyName: 'height', + optionFlags: '--height', + description: + 'The height of the thumbnail, in pixels. Defaults to the original height of the video.', + }, + resizeStrategy: { + name: 'resize_strategy', + kind: 'string', + propertyName: 'resizeStrategy', + optionFlags: '--resize-strategy', + description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', + }, + background: { + name: 'background', + kind: 'string', + propertyName: 'background', + optionFlags: '--background', + description: + 'The background color of the resulting thumbnails in the `"rrggbbaa"` format (red, green, blue, alpha) when used with the `"pad"` resize strategy. The default color is black.', + }, + rotate: { + name: 'rotate', + kind: 'number', + propertyName: 'rotate', + optionFlags: '--rotate', + description: + 'Forces the video to be rotated by the specified degree integer. Currently, only multiples of 90 are supported. We automatically correct the orientation of many videos when the orientation is provided by the camera. This option is only useful for videos requiring rotation because it was not detected by the camera.', + }, + inputCodec: { + name: 'input_codec', + kind: 'string', + propertyName: 'inputCodec', + optionFlags: '--input-codec', + description: + 'Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders.', + }, +} as const + +const fileCompressCommandFields = { + format: { + name: 'format', + kind: 'string', + propertyName: 'format', + optionFlags: '--format', + description: + 'The format of the archive to be created. Supported values are `"tar"` and `"zip"`.\n\nNote that `"tar"` without setting `gzip` to `true` results in an archive that\'s not compressed in any way.', + }, + gzip: { + name: 'gzip', + kind: 'boolean', + propertyName: 'gzip', + optionFlags: '--gzip', + description: + 'Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format.', + }, + password: { + name: 'password', + kind: 'string', + propertyName: 'password', + optionFlags: '--password', + description: + 'This allows you to encrypt all archive contents with a password and thereby protect it against unauthorized use. To unzip the archive, the user will need to provide the password in a text input field prompt.\n\nThis parameter has no effect if the format parameter is anything other than `"zip"`.', + }, + compressionLevel: { + name: 'compression_level', + kind: 'number', + propertyName: 'compressionLevel', + optionFlags: '--compression-level', + description: + 'Determines how fiercely to try to compress the archive. `-0` is compressionless, which is suitable for media that is already compressed. `-1` is fastest with lowest compression. `-9` is slowest with the highest compression.\n\nIf you are using `-0` in combination with the `tar` format with `gzip` enabled, consider setting `gzip: false` instead. This results in a plain Tar archive, meaning it already has no compression.', + }, + fileLayout: { + name: 'file_layout', + kind: 'string', + propertyName: 'fileLayout', + optionFlags: '--file-layout', + description: + 'Determines if the result archive should contain all files in one directory (value for this is `"simple"`) or in subfolders according to the explanation below (value for this is `"advanced"`). The `"relative-path"` option preserves the relative directory structure of the input files.\n\nFiles with same names are numbered in the `"simple"` file layout to avoid naming collisions.', + }, + archiveName: { + name: 'archive_name', + kind: 'string', + propertyName: 'archiveName', + optionFlags: '--archive-name', + description: 'The name of the archive file to be created (without the file extension).', + }, +} as const const imageGenerateCommandDefinition = { outputMode: 'file', outputDescription: 'Write the result to this path', @@ -31,17 +1339,7 @@ const imageGenerateCommandDefinition = { execution: { kind: 'single-step', schema: robotImageGenerateInstructionsSchema, - fieldSpecs: [ - { name: 'model', kind: 'string' }, - { name: 'prompt', kind: 'string' }, - { name: 'format', kind: 'string' }, - { name: 'seed', kind: 'number' }, - { name: 'aspect_ratio', kind: 'string' }, - { name: 'height', kind: 'number' }, - { name: 'width', kind: 'number' }, - { name: 'style', kind: 'string' }, - { name: 'num_outputs', kind: 'number' }, - ], + fields: Object.values(imageGenerateCommandFields), fixedValues: { robot: '/image/generate', result: true, @@ -62,32 +1360,7 @@ const previewGenerateCommandDefinition = { execution: { kind: 'single-step', schema: robotFilePreviewInstructionsSchema, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'strategy', kind: 'json' }, - { name: 'artwork_outer_color', kind: 'string' }, - { name: 'artwork_center_color', kind: 'string' }, - { name: 'waveform_center_color', kind: 'string' }, - { name: 'waveform_outer_color', kind: 'string' }, - { name: 'waveform_height', kind: 'number' }, - { name: 'waveform_width', kind: 'number' }, - { name: 'icon_style', kind: 'string' }, - { name: 'icon_text_color', kind: 'string' }, - { name: 'icon_text_font', kind: 'string' }, - { name: 'icon_text_content', kind: 'string' }, - { name: 'optimize', kind: 'boolean' }, - { name: 'optimize_priority', kind: 'string' }, - { name: 'optimize_progressive', kind: 'boolean' }, - { name: 'clip_format', kind: 'string' }, - { name: 'clip_offset', kind: 'number' }, - { name: 'clip_duration', kind: 'number' }, - { name: 'clip_framerate', kind: 'number' }, - { name: 'clip_loop', kind: 'boolean' }, - ], + fields: Object.values(previewGenerateCommandFields), fixedValues: { robot: '/file/preview', result: true, @@ -108,12 +1381,7 @@ const imageRemoveBackgroundCommandDefinition = { execution: { kind: 'single-step', schema: robotImageBgremoveInstructionsSchema, - fieldSpecs: [ - { name: 'select', kind: 'string' }, - { name: 'format', kind: 'string' }, - { name: 'provider', kind: 'string' }, - { name: 'model', kind: 'string' }, - ], + fields: Object.values(imageRemoveBackgroundCommandFields), fixedValues: { robot: '/image/bgremove', result: true, @@ -134,12 +1402,7 @@ const imageOptimizeCommandDefinition = { execution: { kind: 'single-step', schema: robotImageOptimizeInstructionsSchema, - fieldSpecs: [ - { name: 'priority', kind: 'string' }, - { name: 'progressive', kind: 'boolean' }, - { name: 'preserve_meta_data', kind: 'boolean' }, - { name: 'fix_breaking_images', kind: 'boolean' }, - ], + fields: Object.values(imageOptimizeCommandFields), fixedValues: { robot: '/image/optimize', result: true, @@ -160,53 +1423,7 @@ const imageResizeCommandDefinition = { execution: { kind: 'single-step', schema: robotImageResizeInstructionsSchema, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'zoom', kind: 'boolean' }, - { name: 'crop', kind: 'auto' }, - { name: 'gravity', kind: 'string' }, - { name: 'strip', kind: 'boolean' }, - { name: 'alpha', kind: 'string' }, - { name: 'preclip_alpha', kind: 'string' }, - { name: 'flatten', kind: 'boolean' }, - { name: 'correct_gamma', kind: 'boolean' }, - { name: 'quality', kind: 'number' }, - { name: 'adaptive_filtering', kind: 'boolean' }, - { name: 'background', kind: 'string' }, - { name: 'frame', kind: 'number' }, - { name: 'colorspace', kind: 'string' }, - { name: 'type', kind: 'string' }, - { name: 'sepia', kind: 'number' }, - { name: 'rotation', kind: 'auto' }, - { name: 'compress', kind: 'string' }, - { name: 'blur', kind: 'string' }, - { name: 'blur_regions', kind: 'json' }, - { name: 'brightness', kind: 'number' }, - { name: 'saturation', kind: 'number' }, - { name: 'hue', kind: 'number' }, - { name: 'contrast', kind: 'number' }, - { name: 'watermark_url', kind: 'string' }, - { name: 'watermark_position', kind: 'auto' }, - { name: 'watermark_x_offset', kind: 'number' }, - { name: 'watermark_y_offset', kind: 'number' }, - { name: 'watermark_size', kind: 'string' }, - { name: 'watermark_resize_strategy', kind: 'string' }, - { name: 'watermark_opacity', kind: 'number' }, - { name: 'watermark_repeat_x', kind: 'boolean' }, - { name: 'watermark_repeat_y', kind: 'boolean' }, - { name: 'text', kind: 'json' }, - { name: 'progressive', kind: 'boolean' }, - { name: 'transparent', kind: 'string' }, - { name: 'trim_whitespace', kind: 'boolean' }, - { name: 'clip', kind: 'auto' }, - { name: 'negate', kind: 'boolean' }, - { name: 'density', kind: 'string' }, - { name: 'monochrome', kind: 'boolean' }, - { name: 'shave', kind: 'auto' }, - ], + fields: Object.values(imageResizeCommandFields), fixedValues: { robot: '/image/resize', result: true, @@ -227,17 +1444,7 @@ const documentConvertCommandDefinition = { execution: { kind: 'single-step', schema: robotDocumentConvertInstructionsSchema, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'markdown_format', kind: 'string' }, - { name: 'markdown_theme', kind: 'string' }, - { name: 'pdf_margin', kind: 'string' }, - { name: 'pdf_print_background', kind: 'boolean' }, - { name: 'pdf_format', kind: 'string' }, - { name: 'pdf_display_header_footer', kind: 'boolean' }, - { name: 'pdf_header_template', kind: 'string' }, - { name: 'pdf_footer_template', kind: 'string' }, - ], + fields: Object.values(documentConvertCommandFields), fixedValues: { robot: '/document/convert', result: true, @@ -258,15 +1465,7 @@ const documentOptimizeCommandDefinition = { execution: { kind: 'single-step', schema: robotDocumentOptimizeInstructionsSchema, - fieldSpecs: [ - { name: 'preset', kind: 'string' }, - { name: 'image_dpi', kind: 'number' }, - { name: 'compress_fonts', kind: 'boolean' }, - { name: 'subset_fonts', kind: 'boolean' }, - { name: 'remove_metadata', kind: 'boolean' }, - { name: 'linearize', kind: 'boolean' }, - { name: 'compatibility', kind: 'string' }, - ], + fields: Object.values(documentOptimizeCommandFields), fixedValues: { robot: '/document/optimize', result: true, @@ -287,7 +1486,7 @@ const documentAutoRotateCommandDefinition = { execution: { kind: 'single-step', schema: robotDocumentAutorotateInstructionsSchema, - fieldSpecs: [], + fields: [], fixedValues: { robot: '/document/autorotate', result: true, @@ -308,22 +1507,7 @@ const documentThumbsCommandDefinition = { execution: { kind: 'single-step', schema: robotDocumentThumbsInstructionsSchema, - fieldSpecs: [ - { name: 'page', kind: 'number' }, - { name: 'format', kind: 'string' }, - { name: 'delay', kind: 'number' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'alpha', kind: 'string' }, - { name: 'density', kind: 'string' }, - { name: 'antialiasing', kind: 'boolean' }, - { name: 'colorspace', kind: 'string' }, - { name: 'trim_whitespace', kind: 'boolean' }, - { name: 'pdf_use_cropbox', kind: 'boolean' }, - { name: 'turbo', kind: 'boolean' }, - ], + fields: Object.values(documentThumbsCommandFields), fixedValues: { robot: '/document/thumbs', result: true, @@ -344,34 +1528,7 @@ const audioWaveformCommandDefinition = { execution: { kind: 'single-step', schema: robotAudioWaveformInstructionsSchema, - fieldSpecs: [ - { name: 'ffmpeg', kind: 'json' }, - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'antialiasing', kind: 'auto' }, - { name: 'background_color', kind: 'string' }, - { name: 'center_color', kind: 'string' }, - { name: 'outer_color', kind: 'string' }, - { name: 'style', kind: 'string' }, - { name: 'split_channels', kind: 'boolean' }, - { name: 'zoom', kind: 'number' }, - { name: 'pixels_per_second', kind: 'number' }, - { name: 'bits', kind: 'number' }, - { name: 'start', kind: 'number' }, - { name: 'end', kind: 'number' }, - { name: 'colors', kind: 'string' }, - { name: 'border_color', kind: 'string' }, - { name: 'waveform_style', kind: 'string' }, - { name: 'bar_width', kind: 'number' }, - { name: 'bar_gap', kind: 'number' }, - { name: 'bar_style', kind: 'string' }, - { name: 'axis_label_color', kind: 'string' }, - { name: 'no_axis_labels', kind: 'boolean' }, - { name: 'with_axis_labels', kind: 'boolean' }, - { name: 'amplitude_scale', kind: 'number' }, - { name: 'compression', kind: 'number' }, - ], + fields: Object.values(audioWaveformCommandFields), fixedValues: { robot: '/audio/waveform', result: true, @@ -394,13 +1551,7 @@ const textSpeakCommandDefinition = { execution: { kind: 'single-step', schema: robotTextSpeakInstructionsSchema, - fieldSpecs: [ - { name: 'prompt', kind: 'string' }, - { name: 'provider', kind: 'string' }, - { name: 'target_language', kind: 'string' }, - { name: 'voice', kind: 'string' }, - { name: 'ssml', kind: 'boolean' }, - ], + fields: Object.values(textSpeakCommandFields), fixedValues: { robot: '/text/speak', result: true, @@ -420,18 +1571,7 @@ const videoThumbsCommandDefinition = { execution: { kind: 'single-step', schema: robotVideoThumbsInstructionsSchema, - fieldSpecs: [ - { name: 'ffmpeg', kind: 'json' }, - { name: 'count', kind: 'number' }, - { name: 'offsets', kind: 'json' }, - { name: 'format', kind: 'string' }, - { name: 'width', kind: 'number' }, - { name: 'height', kind: 'number' }, - { name: 'resize_strategy', kind: 'string' }, - { name: 'background', kind: 'string' }, - { name: 'rotate', kind: 'number' }, - { name: 'input_codec', kind: 'string' }, - ], + fields: Object.values(videoThumbsCommandFields), fixedValues: { robot: '/video/thumbs', result: true, @@ -464,14 +1604,7 @@ const fileCompressCommandDefinition = { execution: { kind: 'single-step', schema: robotFileCompressInstructionsSchema, - fieldSpecs: [ - { name: 'format', kind: 'string' }, - { name: 'gzip', kind: 'boolean' }, - { name: 'password', kind: 'string' }, - { name: 'compression_level', kind: 'number' }, - { name: 'file_layout', kind: 'string' }, - { name: 'archive_name', kind: 'string' }, - ], + fields: Object.values(fileCompressCommandFields), fixedValues: { robot: '/file/compress', result: true, @@ -495,7 +1628,7 @@ const fileDecompressCommandDefinition = { execution: { kind: 'single-step', schema: robotFileDecompressInstructionsSchema, - fieldSpecs: [], + fields: [], fixedValues: { robot: '/file/decompress', result: true, @@ -508,6 +1641,8 @@ const fileDecompressCommandDefinition = { class ImageGenerateCommand extends GeneratedNoInputIntentCommand { static override paths = [['image', 'generate']] + static override intentDefinition = imageGenerateCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Generate images from text prompts', @@ -520,69 +1655,30 @@ class ImageGenerateCommand extends GeneratedNoInputIntentCommand { ], }) - protected override getIntentDefinition() { - return imageGenerateCommandDefinition - } + model = createIntentOption(imageGenerateCommandFields.model) - model = Option.String('--model', { - description: 'The AI model to use for image generation. Defaults to google/nano-banana.', - }) - - prompt = Option.String('--prompt', { - description: 'The prompt describing the desired image content.', - required: true, - }) - - format = Option.String('--format', { - description: 'Format of the generated image.', - }) + prompt = createIntentOption(imageGenerateCommandFields.prompt) - seed = Option.String('--seed', { - description: 'Seed for the random number generator.', - validator: t.isNumber(), - }) + format = createIntentOption(imageGenerateCommandFields.format) - aspectRatio = Option.String('--aspect-ratio', { - description: 'Aspect ratio of the generated image.', - }) + seed = createIntentOption(imageGenerateCommandFields.seed) - height = Option.String('--height', { - description: 'Height of the generated image.', - validator: t.isNumber(), - }) + aspectRatio = createIntentOption(imageGenerateCommandFields.aspectRatio) - width = Option.String('--width', { - description: 'Width of the generated image.', - validator: t.isNumber(), - }) + height = createIntentOption(imageGenerateCommandFields.height) - style = Option.String('--style', { - description: 'Style of the generated image.', - }) + width = createIntentOption(imageGenerateCommandFields.width) - numOutputs = Option.String('--num-outputs', { - description: 'Number of image variants to generate.', - validator: t.isNumber(), - }) + style = createIntentOption(imageGenerateCommandFields.style) - protected override getIntentRawValues(): Record { - return { - model: this.model, - prompt: this.prompt, - format: this.format, - seed: this.seed, - aspect_ratio: this.aspectRatio, - height: this.height, - width: this.width, - style: this.style, - num_outputs: this.numOutputs, - } - } + numOutputs = createIntentOption(imageGenerateCommandFields.numOutputs) } class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { static override paths = [['preview', 'generate']] + static override intentDefinition = previewGenerateCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Generate a preview thumbnail', @@ -592,166 +1688,60 @@ class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override getIntentDefinition() { - return previewGenerateCommandDefinition - } - - format = Option.String('--format', { - description: - 'The output format for the generated thumbnail image. If a short video clip is generated using the `clip` strategy, its format is defined by `clip_format`.', - }) - - width = Option.String('--width', { - description: 'Width of the thumbnail, in pixels.', - validator: t.isNumber(), - }) + format = createIntentOption(previewGenerateCommandFields.format) - height = Option.String('--height', { - description: 'Height of the thumbnail, in pixels.', - validator: t.isNumber(), - }) + width = createIntentOption(previewGenerateCommandFields.width) - resizeStrategy = Option.String('--resize-strategy', { - description: - 'To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. This happens, for example, when the dimensions of a frame extracted from a video do not match the chosen `width` and `height` parameters.\n\nSee the list of available [resize strategies](/docs/topics/resize-strategies/) for more details.', - }) + height = createIntentOption(previewGenerateCommandFields.height) - background = Option.String('--background', { - description: - 'The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). The format is `#rrggbb[aa]` (red, green, blue, alpha). Use `#00000000` for a transparent padding.', - }) + resizeStrategy = createIntentOption(previewGenerateCommandFields.resizeStrategy) - strategy = Option.String('--strategy', { - description: - 'Definition of the thumbnail generation process per file category. The parameter must be an object whose keys can be one of the file categories: `audio`, `video`, `image`, `document`, `archive`, `webpage`, and `unknown`. The corresponding value is an array of strategies for the specific file category. See the above section for a list of all available strategies.\n\nFor each file, the Robot will attempt to use the first strategy to generate the thumbnail. If this process fails (e.g., because no artwork is available in a video file), the next strategy is attempted. This is repeated until either a thumbnail is generated or the list is exhausted. Selecting the `icon` strategy as the last entry provides a fallback mechanism to ensure that an appropriate strategy is always available.\n\nThe parameter defaults to the following definition:\n\n```json\n{\n "audio": ["artwork", "waveform", "icon"],\n "video": ["artwork", "frame", "icon"],\n "document": ["page", "icon"],\n "image": ["image", "icon"],\n "webpage": ["render", "icon"],\n "archive": ["icon"],\n "unknown": ["icon"]\n}\n```', - }) + background = createIntentOption(previewGenerateCommandFields.background) - artworkOuterColor = Option.String('--artwork-outer-color', { - description: "The color used in the outer parts of the artwork's gradient.", - }) + strategy = createIntentOption(previewGenerateCommandFields.strategy) - artworkCenterColor = Option.String('--artwork-center-color', { - description: "The color used in the center of the artwork's gradient.", - }) + artworkOuterColor = createIntentOption(previewGenerateCommandFields.artworkOuterColor) - waveformCenterColor = Option.String('--waveform-center-color', { - description: - "The color used in the center of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", - }) + artworkCenterColor = createIntentOption(previewGenerateCommandFields.artworkCenterColor) - waveformOuterColor = Option.String('--waveform-outer-color', { - description: - "The color used in the outer parts of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", - }) + waveformCenterColor = createIntentOption(previewGenerateCommandFields.waveformCenterColor) - waveformHeight = Option.String('--waveform-height', { - description: - 'Height of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', - validator: t.isNumber(), - }) + waveformOuterColor = createIntentOption(previewGenerateCommandFields.waveformOuterColor) - waveformWidth = Option.String('--waveform-width', { - description: - 'Width of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', - validator: t.isNumber(), - }) + waveformHeight = createIntentOption(previewGenerateCommandFields.waveformHeight) - iconStyle = Option.String('--icon-style', { - description: - 'The style of the icon generated if the `icon` strategy is applied. The default style, `with-text`, includes an icon showing the file type and a text box below it, whose content can be controlled by the `icon_text_content` parameter and defaults to the file extension (e.g. MP4, JPEG). The `square` style only includes a square variant of the icon showing the file type. Below are exemplary previews generated for a text file utilizing the different styles:\n\n

`with-text` style:
\n![Image with text style]({{site.asset_cdn}}/assets/images/file-preview/icon-with-text.png)\n

`square` style:
\n![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png)', - }) + waveformWidth = createIntentOption(previewGenerateCommandFields.waveformWidth) - iconTextColor = Option.String('--icon-text-color', { - description: - 'The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied.', - }) + iconStyle = createIntentOption(previewGenerateCommandFields.iconStyle) - iconTextFont = Option.String('--icon-text-font', { - description: - 'The font family of the text used in the icon. Only used if the `icon` strategy is applied. [Here](/docs/supported-formats/fonts/) is a list of all supported fonts.', - }) + iconTextColor = createIntentOption(previewGenerateCommandFields.iconTextColor) - iconTextContent = Option.String('--icon-text-content', { - description: - 'The content of the text box in generated icons. Only used if the `icon_style` parameter is set to `with-text`. The default value, `extension`, adds the file extension (e.g. MP4, JPEG) to the icon. The value `none` can be used to render an empty text box, which is useful if no text should not be included in the raster image, but some place should be reserved in the image for later overlaying custom text over the image using HTML etc.', - }) + iconTextFont = createIntentOption(previewGenerateCommandFields.iconTextFont) - optimize = Option.Boolean('--optimize', { - description: - "Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. If enabled, the images will be optimized using [🤖/image/optimize](/docs/robots/image-optimize/).", - }) + iconTextContent = createIntentOption(previewGenerateCommandFields.iconTextContent) - optimizePriority = Option.String('--optimize-priority', { - description: - 'Specifies whether conversion speed or compression ratio is prioritized when optimizing images. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-priority) for more details.', - }) + optimize = createIntentOption(previewGenerateCommandFields.optimize) - optimizeProgressive = Option.Boolean('--optimize-progressive', { - description: - 'Specifies whether images should be interlaced, which makes the result image load progressively in browsers. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-progressive) for more details.', - }) + optimizePriority = createIntentOption(previewGenerateCommandFields.optimizePriority) - clipFormat = Option.String('--clip-format', { - description: - 'The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied.\n\nPlease consult the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types) for detailed information about the image formats and their characteristics. GIF enjoys the broadest support in software, but only supports a limit color palette. APNG supports a variety of color depths, but its lossless compression produces large images for videos. AVIF is a modern image format that offers great compression, but proper support for animations is still lacking in some browsers. WebP on the other hand, enjoys broad support while offering a great balance between small file sizes and good visual quality, making it the default clip format.', - }) + optimizeProgressive = createIntentOption(previewGenerateCommandFields.optimizeProgressive) - clipOffset = Option.String('--clip-offset', { - description: - 'The start position in seconds of where the clip is cut. Only used if the `clip` strategy for video files is applied. Be aware that for larger video only the first few MBs of the file may be imported to improve speed. Larger offsets may seek to a position outside of the imported part and thus fail to generate a clip.', - validator: t.isNumber(), - }) + clipFormat = createIntentOption(previewGenerateCommandFields.clipFormat) - clipDuration = Option.String('--clip-duration', { - description: - 'The duration in seconds of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a longer clip duration also results in a larger file size, which might be undesirable for previews.', - validator: t.isNumber(), - }) + clipOffset = createIntentOption(previewGenerateCommandFields.clipOffset) - clipFramerate = Option.String('--clip-framerate', { - description: - 'The framerate of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a higher framerate appears smoother but also results in a larger file size, which might be undesirable for previews.', - validator: t.isNumber(), - }) + clipDuration = createIntentOption(previewGenerateCommandFields.clipDuration) - clipLoop = Option.Boolean('--clip-loop', { - description: - 'Specifies whether the generated animated image should loop forever (`true`) or stop after playing the animation once (`false`). Only used if the `clip` strategy for video files is applied.', - }) + clipFramerate = createIntentOption(previewGenerateCommandFields.clipFramerate) - protected override getIntentRawValues(): Record { - return { - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - strategy: this.strategy, - artwork_outer_color: this.artworkOuterColor, - artwork_center_color: this.artworkCenterColor, - waveform_center_color: this.waveformCenterColor, - waveform_outer_color: this.waveformOuterColor, - waveform_height: this.waveformHeight, - waveform_width: this.waveformWidth, - icon_style: this.iconStyle, - icon_text_color: this.iconTextColor, - icon_text_font: this.iconTextFont, - icon_text_content: this.iconTextContent, - optimize: this.optimize, - optimize_priority: this.optimizePriority, - optimize_progressive: this.optimizeProgressive, - clip_format: this.clipFormat, - clip_offset: this.clipOffset, - clip_duration: this.clipDuration, - clip_framerate: this.clipFramerate, - clip_loop: this.clipLoop, - } - } + clipLoop = createIntentOption(previewGenerateCommandFields.clipLoop) } class ImageRemoveBackgroundCommand extends GeneratedStandardFileIntentCommand { static override paths = [['image', 'remove-background']] + static override intentDefinition = imageRemoveBackgroundCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Remove the background from images', @@ -761,40 +1751,20 @@ class ImageRemoveBackgroundCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override getIntentDefinition() { - return imageRemoveBackgroundCommandDefinition - } - - select = Option.String('--select', { - description: 'Region to select and keep in the image. The other region is removed.', - }) - - format = Option.String('--format', { - description: 'Format of the generated image.', - }) + select = createIntentOption(imageRemoveBackgroundCommandFields.select) - provider = Option.String('--provider', { - description: 'Provider to use for removing the background.', - }) + format = createIntentOption(imageRemoveBackgroundCommandFields.format) - model = Option.String('--model', { - description: - 'Provider-specific model to use for removing the background. Mostly intended for testing and evaluation.', - }) + provider = createIntentOption(imageRemoveBackgroundCommandFields.provider) - protected override getIntentRawValues(): Record { - return { - select: this.select, - format: this.format, - provider: this.provider, - model: this.model, - } - } + model = createIntentOption(imageRemoveBackgroundCommandFields.model) } class ImageOptimizeCommand extends GeneratedStandardFileIntentCommand { static override paths = [['image', 'optimize']] + static override intentDefinition = imageOptimizeCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Optimize images without quality loss', @@ -804,43 +1774,20 @@ class ImageOptimizeCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override getIntentDefinition() { - return imageOptimizeCommandDefinition - } - - priority = Option.String('--priority', { - description: - 'Provides different algorithms for better or worse compression for your images, but that run slower or faster. The value `"conversion-speed"` will result in an average compression ratio of 18%. `"compression-ratio"` will result in an average compression ratio of 31%.', - }) - - progressive = Option.Boolean('--progressive', { - description: - 'Interlaces the image if set to `true`, which makes the result image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the image which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a loss of about 10% of the file size reduction.', - }) + priority = createIntentOption(imageOptimizeCommandFields.priority) - preserveMetaData = Option.Boolean('--preserve-meta-data', { - description: - "Specifies if the image's metadata should be preserved during the optimization, or not. If it is not preserved, the file size is even further reduced. But be aware that this could strip a photographer's copyright information, which for obvious reasons can be frowned upon.", - }) + progressive = createIntentOption(imageOptimizeCommandFields.progressive) - fixBreakingImages = Option.Boolean('--fix-breaking-images', { - description: - 'If set to `true` this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies. This can sometimes result in a larger file size, though.', - }) + preserveMetaData = createIntentOption(imageOptimizeCommandFields.preserveMetaData) - protected override getIntentRawValues(): Record { - return { - priority: this.priority, - progressive: this.progressive, - preserve_meta_data: this.preserveMetaData, - fix_breaking_images: this.fixBreakingImages, - } - } + fixBreakingImages = createIntentOption(imageOptimizeCommandFields.fixBreakingImages) } class ImageResizeCommand extends GeneratedStandardFileIntentCommand { static override paths = [['image', 'resize']] + static override intentDefinition = imageResizeCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Convert, resize, or watermark images', @@ -848,297 +1795,102 @@ class ImageResizeCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit image resize --input input.png --out output.png']], }) - protected override getIntentDefinition() { - return imageResizeCommandDefinition - } + format = createIntentOption(imageResizeCommandFields.format) - format = Option.String('--format', { - description: - 'The output format for the modified image.\n\nSome of the most important available formats are `"jpg"`, `"png"`, `"gif"`, and `"tiff"`. For a complete lists of all formats that we can write to please check [our supported image formats list](/docs/supported-formats/image-formats/).\n\nIf `null` (default), then the input image\'s format will be used as the output format.\n\nIf you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead.', - }) - - width = Option.String('--width', { - description: - 'Width of the result in pixels. If not specified, will default to the width of the original.', - validator: t.isNumber(), - }) - - height = Option.String('--height', { - description: - 'Height of the new image, in pixels. If not specified, will default to the height of the input image.', - validator: t.isNumber(), - }) - - resizeStrategy = Option.String('--resize-strategy', { - description: 'See the list of available [resize strategies](/docs/topics/resize-strategies/).', - }) + width = createIntentOption(imageResizeCommandFields.width) - zoom = Option.Boolean('--zoom', { - description: - 'If this is set to `false`, smaller images will not be stretched to the desired width and height. For details about the impact of zooming for your preferred resize strategy, see the list of available [resize strategies](/docs/topics/resize-strategies/).', - }) + height = createIntentOption(imageResizeCommandFields.height) - crop = Option.String('--crop', { - description: - 'Specify an object containing coordinates for the top left and bottom right corners of the rectangle to be cropped from the original image(s). The coordinate system is rooted in the top left corner of the image. Values can be integers for absolute pixel values or strings for percentage based values.\n\nFor example:\n\n```json\n{\n "x1": 80,\n "y1": 100,\n "x2": "60%",\n "y2": "80%"\n}\n```\n\nThis will crop the area from `(80, 100)` to `(600, 800)` from a 1000×1000 pixels image, which is a square whose width is 520px and height is 700px. If `crop` is set, the width and height parameters are ignored, and the `resize_strategy` is set to `crop` automatically.\n\nYou can also use a JSON string of such an object with coordinates in similar fashion:\n\n```json\n"{\\"x1\\": , \\"y1\\": , \\"x2\\": , \\"y2\\": }"\n```\n\nTo crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/).', - }) + resizeStrategy = createIntentOption(imageResizeCommandFields.resizeStrategy) - gravity = Option.String('--gravity', { - description: - 'The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined.', - }) + zoom = createIntentOption(imageResizeCommandFields.zoom) - strip = Option.Boolean('--strip', { - description: - 'Strips all metadata from the image. This is useful to keep thumbnails as small as possible.', - }) + crop = createIntentOption(imageResizeCommandFields.crop) - alpha = Option.String('--alpha', { - description: 'Gives control of the alpha/matte channel of an image.', - }) + gravity = createIntentOption(imageResizeCommandFields.gravity) - preclipAlpha = Option.String('--preclip-alpha', { - description: - 'Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`.', - }) + strip = createIntentOption(imageResizeCommandFields.strip) - flatten = Option.Boolean('--flatten', { - description: - 'Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#layers).\n\nTo preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter.', - }) + alpha = createIntentOption(imageResizeCommandFields.alpha) - correctGamma = Option.Boolean('--correct-gamma', { - description: - 'Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html).', - }) + preclipAlpha = createIntentOption(imageResizeCommandFields.preclipAlpha) - quality = Option.String('--quality', { - description: - 'Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', - validator: t.isNumber(), - }) + flatten = createIntentOption(imageResizeCommandFields.flatten) - adaptiveFiltering = Option.Boolean('--adaptive-filtering', { - description: - 'Controls the image compression for PNG images. Setting to `true` results in smaller file size, while increasing processing time. It is encouraged to keep this option disabled.', - }) + correctGamma = createIntentOption(imageResizeCommandFields.correctGamma) - background = Option.String('--background', { - description: - 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (used for the `pad` resize strategy).\n\n**Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`.', - }) + quality = createIntentOption(imageResizeCommandFields.quality) - frame = Option.String('--frame', { - description: - 'Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. Specify `1` to use the first frame, `2` to use the second, and so on. `null` means all frames.', - validator: t.isNumber(), - }) + adaptiveFiltering = createIntentOption(imageResizeCommandFields.adaptiveFiltering) - colorspace = Option.String('--colorspace', { - description: - 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace). Please note that if you were using `"RGB"`, we recommend using `"sRGB"` instead as of 2014-02-04. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might have to use this parameter in combination with `type: "TrueColor"`.', - }) + background = createIntentOption(imageResizeCommandFields.background) - type = Option.String('--type', { - description: - 'Sets the image color type. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#type). If you\'re using `colorspace`, ImageMagick might try to find the most efficient based on the color of an image, and default to e.g. `"Gray"`. To force colors, you could e.g. set this parameter to `"TrueColor"`', - }) + frame = createIntentOption(imageResizeCommandFields.frame) - sepia = Option.String('--sepia', { - description: 'Applies a sepia tone effect in percent.', - validator: t.isNumber(), - }) + colorspace = createIntentOption(imageResizeCommandFields.colorspace) - rotation = Option.String('--rotation', { - description: - 'Determines whether the image should be rotated. Use any number to specify the rotation angle in degrees (e.g., `90`, `180`, `270`, `360`, or precise values like `2.9`). Use the value `true` or `"auto"` to auto-rotate images that are rotated incorrectly or depend on EXIF rotation settings. Otherwise, use `false` to disable auto-fixing altogether.', - }) + type = createIntentOption(imageResizeCommandFields.type) - compress = Option.String('--compress', { - description: - 'Specifies pixel compression for when the image is written. Compression is disabled by default.\n\nPlease also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', - }) + sepia = createIntentOption(imageResizeCommandFields.sepia) - blur = Option.String('--blur', { - description: - 'Specifies gaussian blur, using a value with the form `{radius}x{sigma}`. The radius value specifies the size of area the operator should look at when spreading pixels, and should typically be either `"0"` or at least two times the sigma value. The sigma value is an approximation of how many pixels the image is "spread"; think of it as the size of the brush used to blur the image. This number is a floating point value, enabling small values like `"0.5"` to be used.', - }) + rotation = createIntentOption(imageResizeCommandFields.rotation) - blurRegions = Option.String('--blur-regions', { - description: - 'Specifies an array of ellipse objects that should be blurred on the image. Each object has the following keys: `x`, `y`, `width`, `height`. If `blur_regions` has a value, then the `blur` parameter is used as the strength of the blur for each region.', - }) + compress = createIntentOption(imageResizeCommandFields.compress) - brightness = Option.String('--brightness', { - description: - 'Increases or decreases the brightness of the image by using a multiplier. For example `1.5` would increase the brightness by 50%, and `0.75` would decrease the brightness by 25%.', - validator: t.isNumber(), - }) + blur = createIntentOption(imageResizeCommandFields.blur) - saturation = Option.String('--saturation', { - description: - 'Increases or decreases the saturation of the image by using a multiplier. For example `1.5` would increase the saturation by 50%, and `0.75` would decrease the saturation by 25%.', - validator: t.isNumber(), - }) + blurRegions = createIntentOption(imageResizeCommandFields.blurRegions) - hue = Option.String('--hue', { - description: - 'Changes the hue by rotating the color of the image. The value `100` would produce no change whereas `0` and `200` will negate the colors in the image.', - validator: t.isNumber(), - }) + brightness = createIntentOption(imageResizeCommandFields.brightness) - contrast = Option.String('--contrast', { - description: - 'Adjusts the contrast of the image. A value of `1` produces no change. Values below `1` decrease contrast (with `0` being minimum contrast), and values above `1` increase contrast (with `2` being maximum contrast). This works like the `brightness` parameter.', - validator: t.isNumber(), - }) + saturation = createIntentOption(imageResizeCommandFields.saturation) - watermarkUrl = Option.String('--watermark-url', { - description: - 'A URL indicating a PNG image to be overlaid above this image. Please note that you can also [supply the watermark via another Assembly Step](/docs/topics/use-parameter/#supplying-the-watermark-via-an-assembly-step). With watermarking you can add an image onto another image. This is usually used for logos.', - }) + hue = createIntentOption(imageResizeCommandFields.hue) - watermarkPosition = Option.String('--watermark-position', { - description: - 'The position at which the watermark is placed. The available options are `"center"`, `"top"`, `"bottom"`, `"left"`, and `"right"`. You can also combine options, such as `"bottom-right"`.\n\nAn array of possible values can also be specified, in which case one value will be selected at random, such as `[ "center", "left", "bottom-left", "bottom-right" ]`.\n\nThis setting puts the watermark in the specified corner. To use a specific pixel offset for the watermark, you will need to add the padding to the image itself.', - }) + contrast = createIntentOption(imageResizeCommandFields.contrast) - watermarkXOffset = Option.String('--watermark-x-offset', { - description: - "The x-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", - validator: t.isNumber(), - }) + watermarkUrl = createIntentOption(imageResizeCommandFields.watermarkUrl) - watermarkYOffset = Option.String('--watermark-y-offset', { - description: - "The y-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", - validator: t.isNumber(), - }) + watermarkPosition = createIntentOption(imageResizeCommandFields.watermarkPosition) - watermarkSize = Option.String('--watermark-size', { - description: - 'The size of the watermark, as a percentage.\n\nFor example, a value of `"50%"` means that size of the watermark will be 50% of the size of image on which it is placed. The exact sizing depends on `watermark_resize_strategy`, too.', - }) + watermarkXOffset = createIntentOption(imageResizeCommandFields.watermarkXOffset) - watermarkResizeStrategy = Option.String('--watermark-resize-strategy', { - description: - 'Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`.\n\nTo explain how the resize strategies work, let\'s assume our target image size is 800×800 pixels and our watermark image is 400×300 pixels. Let\'s also assume, the `watermark_size` parameter is set to `"25%"`.\n\nFor the `"fit"` resize strategy, the watermark is scaled so that the longer side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the width is the longer side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 200×150 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 400×300 pixels (so just left at its original size).\n\nFor the `"min_fit"` resize strategy, the watermark is scaled so that the shorter side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the height is the shorter side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 267×200 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 533×400 pixels (so larger than its original size).\n\nFor the `"stretch"` resize strategy, the watermark is stretched (meaning, it is resized without keeping its aspect ratio in mind) so that both sides take up 25% of the corresponding image side. Since our image is 800×800 pixels, for a watermark size of 25% the watermark would be resized to 200×200 pixels. Its height would appear stretched, because keeping the aspect ratio in mind it would be resized to 200×150 pixels instead.\n\nFor the `"area"` resize strategy, the watermark is resized (keeping its aspect ratio in check) so that it covers `"xx%"` of the image\'s surface area. The value from `watermark_size` is used for the percentage area size.', - }) + watermarkYOffset = createIntentOption(imageResizeCommandFields.watermarkYOffset) - watermarkOpacity = Option.String('--watermark-opacity', { - description: - 'The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque.\n\nFor example, a value of `0.5` means the watermark will be 50% transparent, allowing the underlying image to show through. This is useful for subtle branding or when you want the watermark to be less obtrusive.', - validator: t.isNumber(), - }) + watermarkSize = createIntentOption(imageResizeCommandFields.watermarkSize) - watermarkRepeatX = Option.Boolean('--watermark-repeat-x', { - description: - 'When set to `true`, the watermark will be repeated horizontally across the entire width of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark.', - }) + watermarkResizeStrategy = createIntentOption(imageResizeCommandFields.watermarkResizeStrategy) - watermarkRepeatY = Option.Boolean('--watermark-repeat-y', { - description: - 'When set to `true`, the watermark will be repeated vertically across the entire height of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image. Can be combined with `watermark_repeat_x` to tile in both directions.', - }) + watermarkOpacity = createIntentOption(imageResizeCommandFields.watermarkOpacity) - text = Option.String('--text', { - description: - 'Text overlays to be applied to the image. Can be either a single text object or an array of text objects. Each text object contains text rules. The following text parameters are intended to be used as properties for your text overlays. Here is an example:\n\n```json\n"watermarked": {\n "use": "resized",\n "robot": "/image/resize",\n "text": [\n {\n "text": "© 2018 Transloadit.com",\n "size": 12,\n "font": "Ubuntu",\n "color": "#eeeeee",\n "valign": "bottom",\n "align": "right",\n "x_offset": 16,\n "y_offset": -10\n }\n ]\n}\n```', - }) + watermarkRepeatX = createIntentOption(imageResizeCommandFields.watermarkRepeatX) - progressive = Option.Boolean('--progressive', { - description: - 'Interlaces the image if set to `true`, which makes the image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the images which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a cost of a file size increase by around 10%.', - }) + watermarkRepeatY = createIntentOption(imageResizeCommandFields.watermarkRepeatY) - transparent = Option.String('--transparent', { - description: 'Make this color transparent within the image. Example: `"255,255,255"`.', - }) + text = createIntentOption(imageResizeCommandFields.text) - trimWhitespace = Option.Boolean('--trim-whitespace', { - description: - 'This determines if additional whitespace around the image should first be trimmed away. If you set this to `true` this parameter removes any edges that are exactly the same color as the corner pixels.', - }) + progressive = createIntentOption(imageResizeCommandFields.progressive) - clip = Option.String('--clip', { - description: - 'Apply the clipping path to other operations in the resize job, if one is present. If set to `true`, it will automatically take the first clipping path. If set to a String it finds a clipping path by that name.', - }) + transparent = createIntentOption(imageResizeCommandFields.transparent) - negate = Option.Boolean('--negate', { - description: - 'Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping.', - }) + trimWhitespace = createIntentOption(imageResizeCommandFields.trimWhitespace) - density = Option.String('--density', { - description: - 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image is unsharp, please try increasing density.', - }) + clip = createIntentOption(imageResizeCommandFields.clip) - monochrome = Option.Boolean('--monochrome', { - description: - 'Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel.', - }) + negate = createIntentOption(imageResizeCommandFields.negate) - shave = Option.String('--shave', { - description: - 'Shave pixels from the image edges. The value should be in the format `width` or `width`x`height` to specify the number of pixels to remove from each side.', - }) + density = createIntentOption(imageResizeCommandFields.density) - protected override getIntentRawValues(): Record { - return { - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - zoom: this.zoom, - crop: this.crop, - gravity: this.gravity, - strip: this.strip, - alpha: this.alpha, - preclip_alpha: this.preclipAlpha, - flatten: this.flatten, - correct_gamma: this.correctGamma, - quality: this.quality, - adaptive_filtering: this.adaptiveFiltering, - background: this.background, - frame: this.frame, - colorspace: this.colorspace, - type: this.type, - sepia: this.sepia, - rotation: this.rotation, - compress: this.compress, - blur: this.blur, - blur_regions: this.blurRegions, - brightness: this.brightness, - saturation: this.saturation, - hue: this.hue, - contrast: this.contrast, - watermark_url: this.watermarkUrl, - watermark_position: this.watermarkPosition, - watermark_x_offset: this.watermarkXOffset, - watermark_y_offset: this.watermarkYOffset, - watermark_size: this.watermarkSize, - watermark_resize_strategy: this.watermarkResizeStrategy, - watermark_opacity: this.watermarkOpacity, - watermark_repeat_x: this.watermarkRepeatX, - watermark_repeat_y: this.watermarkRepeatY, - text: this.text, - progressive: this.progressive, - transparent: this.transparent, - trim_whitespace: this.trimWhitespace, - clip: this.clip, - negate: this.negate, - density: this.density, - monochrome: this.monochrome, - shave: this.shave, - } - } + monochrome = createIntentOption(imageResizeCommandFields.monochrome) + + shave = createIntentOption(imageResizeCommandFields.shave) } class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'convert']] + static override intentDefinition = documentConvertCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Convert documents into different formats', @@ -1151,73 +1903,30 @@ class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override getIntentDefinition() { - return documentConvertCommandDefinition - } - - format = Option.String('--format', { - description: 'The desired format for document conversion.', - required: true, - }) - - markdownFormat = Option.String('--markdown-format', { - description: - 'Markdown can be represented in several [variants](https://www.iana.org/assignments/markdown-variants/markdown-variants.xhtml), so when using this Robot to transform Markdown into HTML please specify which revision is being used.', - }) + format = createIntentOption(documentConvertCommandFields.format) - markdownTheme = Option.String('--markdown-theme', { - description: - 'This parameter overhauls your Markdown files styling based on several canned presets.', - }) + markdownFormat = createIntentOption(documentConvertCommandFields.markdownFormat) - pdfMargin = Option.String('--pdf-margin', { - description: - 'PDF Paper margins, separated by `,` and with units.\n\nWe support the following unit values: `px`, `in`, `cm`, `mm`.\n\nCurrently this parameter is only supported when converting from `html`.', - }) + markdownTheme = createIntentOption(documentConvertCommandFields.markdownTheme) - pdfPrintBackground = Option.Boolean('--pdf-print-background', { - description: - 'Print PDF background graphics.\n\nCurrently this parameter is only supported when converting from `html`.', - }) + pdfMargin = createIntentOption(documentConvertCommandFields.pdfMargin) - pdfFormat = Option.String('--pdf-format', { - description: - 'PDF paper format.\n\nCurrently this parameter is only supported when converting from `html`.', - }) + pdfPrintBackground = createIntentOption(documentConvertCommandFields.pdfPrintBackground) - pdfDisplayHeaderFooter = Option.Boolean('--pdf-display-header-footer', { - description: - 'Display PDF header and footer.\n\nCurrently this parameter is only supported when converting from `html`.', - }) + pdfFormat = createIntentOption(documentConvertCommandFields.pdfFormat) - pdfHeaderTemplate = Option.String('--pdf-header-template', { - description: - 'HTML template for the PDF print header.\n\nShould be valid HTML markup with following classes used to inject printing values into them:\n- `date` formatted print date\n- `title` document title\n- `url` document location\n- `pageNumber` current page number\n- `totalPages` total pages in the document\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number at the top of a page you\'d use the following HTML for the header template:\n\n```html\n
\n```', - }) + pdfDisplayHeaderFooter = createIntentOption(documentConvertCommandFields.pdfDisplayHeaderFooter) - pdfFooterTemplate = Option.String('--pdf-footer-template', { - description: - 'HTML template for the PDF print footer.\n\nShould use the same format as the `pdf_header_template`.\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number in the footer you\'d use the following HTML for the footer template:\n\n```html\n
\n```', - }) + pdfHeaderTemplate = createIntentOption(documentConvertCommandFields.pdfHeaderTemplate) - protected override getIntentRawValues(): Record { - return { - format: this.format, - markdown_format: this.markdownFormat, - markdown_theme: this.markdownTheme, - pdf_margin: this.pdfMargin, - pdf_print_background: this.pdfPrintBackground, - pdf_format: this.pdfFormat, - pdf_display_header_footer: this.pdfDisplayHeaderFooter, - pdf_header_template: this.pdfHeaderTemplate, - pdf_footer_template: this.pdfFooterTemplate, - } - } + pdfFooterTemplate = createIntentOption(documentConvertCommandFields.pdfFooterTemplate) } class DocumentOptimizeCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'optimize']] + static override intentDefinition = documentOptimizeCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Reduce PDF file size', @@ -1227,62 +1936,26 @@ class DocumentOptimizeCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override getIntentDefinition() { - return documentOptimizeCommandDefinition - } - - preset = Option.String('--preset', { - description: - 'The quality preset to use for optimization. Each preset provides a different balance between file size and quality:\n\n- `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI.\n- `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI.\n- `printer` - High quality suitable for printing. Images are kept at 300 DPI.\n- `prepress` - Highest quality for professional printing. Minimal compression applied.', - }) - - imageDpi = Option.String('--image-dpi', { - description: - 'Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset.\n\nHigher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed.\n\nCommon values:\n- 72 - Screen viewing\n- 150 - eBooks and general documents\n- 300 - Print quality\n- 600 - High-quality print', - validator: t.isNumber(), - }) + preset = createIntentOption(documentOptimizeCommandFields.preset) - compressFonts = Option.Boolean('--compress-fonts', { - description: - 'Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size.', - }) + imageDpi = createIntentOption(documentOptimizeCommandFields.imageDpi) - subsetFonts = Option.Boolean('--subset-fonts', { - description: - "Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. This can significantly reduce file size for documents that only use a small portion of a font's character set.", - }) + compressFonts = createIntentOption(documentOptimizeCommandFields.compressFonts) - removeMetadata = Option.Boolean('--remove-metadata', { - description: - 'Whether to strip document metadata (title, author, keywords, etc.) from the PDF. This can provide a small reduction in file size and may be useful for privacy.', - }) + subsetFonts = createIntentOption(documentOptimizeCommandFields.subsetFonts) - linearize = Option.Boolean('--linearize', { - description: - 'Whether to linearize (optimize for Fast Web View) the output PDF. Linearized PDFs can begin displaying in a browser before they are fully downloaded, improving the user experience for web delivery.', - }) + removeMetadata = createIntentOption(documentOptimizeCommandFields.removeMetadata) - compatibility = Option.String('--compatibility', { - description: - 'The PDF version compatibility level. Lower versions have broader compatibility but fewer features. Higher versions support more advanced features but may not open in older PDF readers.\n\n- `1.4` - Acrobat 5 compatibility, most widely supported\n- `1.5` - Acrobat 6 compatibility\n- `1.6` - Acrobat 7 compatibility\n- `1.7` - Acrobat 8+ compatibility (default)\n- `2.0` - PDF 2.0 standard', - }) + linearize = createIntentOption(documentOptimizeCommandFields.linearize) - protected override getIntentRawValues(): Record { - return { - preset: this.preset, - image_dpi: this.imageDpi, - compress_fonts: this.compressFonts, - subset_fonts: this.subsetFonts, - remove_metadata: this.removeMetadata, - linearize: this.linearize, - compatibility: this.compatibility, - } - } + compatibility = createIntentOption(documentOptimizeCommandFields.compatibility) } class DocumentAutoRotateCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'auto-rotate']] + static override intentDefinition = documentAutoRotateCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Auto-rotate documents to the correct orientation', @@ -1291,19 +1964,13 @@ class DocumentAutoRotateCommand extends GeneratedStandardFileIntentCommand { ['Run the command', 'transloadit document auto-rotate --input input.pdf --out output.pdf'], ], }) - - protected override getIntentDefinition() { - return documentAutoRotateCommandDefinition - } - - protected override getIntentRawValues(): Record { - return {} - } } class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { static override paths = [['document', 'thumbs']] + static override intentDefinition = documentThumbsCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Extract thumbnail images from documents', @@ -1311,106 +1978,40 @@ class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit document thumbs --input input.pdf --out output/']], }) - protected override getIntentDefinition() { - return documentThumbsCommandDefinition - } - - page = Option.String('--page', { - description: - 'The PDF page that you want to convert to an image. By default the value is `null` which means that all pages will be converted into images.', - validator: t.isNumber(), - }) - - format = Option.String('--format', { - description: - 'The format of the extracted image(s).\n\nIf you specify the value `"gif"`, then an animated gif cycling through all pages is created. Please check out [this demo](/demos/document-processing/convert-all-pages-of-a-document-into-an-animated-gif/) to learn more about this.', - }) + page = createIntentOption(documentThumbsCommandFields.page) - delay = Option.String('--delay', { - description: - 'If your output format is `"gif"` then this parameter sets the number of 100th seconds to pass before the next frame is shown in the animation. Set this to `100` for example to allow 1 second to pass between the frames of the animated gif.\n\nIf your output format is not `"gif"`, then this parameter does not have any effect.', - validator: t.isNumber(), - }) + format = createIntentOption(documentThumbsCommandFields.format) - width = Option.String('--width', { - description: - 'Width of the new image, in pixels. If not specified, will default to the width of the input image', - validator: t.isNumber(), - }) + delay = createIntentOption(documentThumbsCommandFields.delay) - height = Option.String('--height', { - description: - 'Height of the new image, in pixels. If not specified, will default to the height of the input image', - validator: t.isNumber(), - }) + width = createIntentOption(documentThumbsCommandFields.width) - resizeStrategy = Option.String('--resize-strategy', { - description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', - }) + height = createIntentOption(documentThumbsCommandFields.height) - background = Option.String('--background', { - description: - 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (only used for the pad resize strategy).\n\nBy default, the background of transparent images is changed to white. For details about how to preserve transparency across all image types, see [this demo](/demos/image-manipulation/properly-preserve-transparency-across-all-image-types/).', - }) + resizeStrategy = createIntentOption(documentThumbsCommandFields.resizeStrategy) - alpha = Option.String('--alpha', { - description: - 'Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency.\n\nFor a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha).', - }) + background = createIntentOption(documentThumbsCommandFields.background) - density = Option.String('--density', { - description: - 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image has a low resolution, please try using the density parameter to resolve that.', - }) + alpha = createIntentOption(documentThumbsCommandFields.alpha) - antialiasing = Option.Boolean('--antialiasing', { - description: - 'Controls whether or not antialiasing is used to remove jagged edges from text or images in a document.', - }) + density = createIntentOption(documentThumbsCommandFields.density) - colorspace = Option.String('--colorspace', { - description: - 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace).\n\nPlease note that if you were using `"RGB"`, we recommend using `"sRGB"`. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might then have to use this parameter.', - }) + antialiasing = createIntentOption(documentThumbsCommandFields.antialiasing) - trimWhitespace = Option.Boolean('--trim-whitespace', { - description: - "This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. If you set this to `true` only the real PDF page contents will be shown in the image.\n\nIf you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`.", - }) + colorspace = createIntentOption(documentThumbsCommandFields.colorspace) - pdfUseCropbox = Option.Boolean('--pdf-use-cropbox', { - description: - "Some PDF documents lie about their dimensions. For instance they'll say they are landscape, but when opened in decent Desktop readers, it's really in portrait mode. This can happen if the document has a cropbox defined. When this option is enabled (by default), the cropbox is leading in determining the dimensions of the resulting thumbnails.", - }) + trimWhitespace = createIntentOption(documentThumbsCommandFields.trimWhitespace) - turbo = Option.Boolean('--turbo', { - description: - "If you set this to `false`, the robot will not emit files as they become available. This is useful if you are only interested in the final result and not in the intermediate steps.\n\nAlso, extracted pages will be resized a lot faster as they are sent off to other machines for the resizing. This is especially useful for large documents with many pages to get up to 20 times faster processing.\n\nTurbo Mode increases pricing, though, in that the input document's file size is added for every extracted page. There are no performance benefits nor increased charges for single-page documents.", - }) + pdfUseCropbox = createIntentOption(documentThumbsCommandFields.pdfUseCropbox) - protected override getIntentRawValues(): Record { - return { - page: this.page, - format: this.format, - delay: this.delay, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - alpha: this.alpha, - density: this.density, - antialiasing: this.antialiasing, - colorspace: this.colorspace, - trim_whitespace: this.trimWhitespace, - pdf_use_cropbox: this.pdfUseCropbox, - turbo: this.turbo, - } - } + turbo = createIntentOption(documentThumbsCommandFields.turbo) } class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { static override paths = [['audio', 'waveform']] + static override intentDefinition = audioWaveformCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Generate waveform images from audio', @@ -1420,176 +2021,64 @@ class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override getIntentDefinition() { - return audioWaveformCommandDefinition - } - - ffmpeg = Option.String('--ffmpeg', { - description: - 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', - }) - - format = Option.String('--format', { - description: - 'The format of the result file. Can be `"image"` or `"json"`. If `"image"` is supplied, a PNG image will be created, otherwise a JSON file.', - }) + ffmpeg = createIntentOption(audioWaveformCommandFields.ffmpeg) - width = Option.String('--width', { - description: 'The width of the resulting image if the format `"image"` was selected.', - validator: t.isNumber(), - }) + format = createIntentOption(audioWaveformCommandFields.format) - height = Option.String('--height', { - description: 'The height of the resulting image if the format `"image"` was selected.', - validator: t.isNumber(), - }) + width = createIntentOption(audioWaveformCommandFields.width) - antialiasing = Option.String('--antialiasing', { - description: - 'Either a value of `0` or `1`, or `true`/`false`, corresponding to if you want to enable antialiasing to achieve smoother edges in the waveform graph or not.', - }) + height = createIntentOption(audioWaveformCommandFields.height) - backgroundColor = Option.String('--background-color', { - description: - 'The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected.', - }) + antialiasing = createIntentOption(audioWaveformCommandFields.antialiasing) - centerColor = Option.String('--center-color', { - description: - 'The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', - }) + backgroundColor = createIntentOption(audioWaveformCommandFields.backgroundColor) - outerColor = Option.String('--outer-color', { - description: - 'The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', - }) + centerColor = createIntentOption(audioWaveformCommandFields.centerColor) - style = Option.String('--style', { - description: - 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', - }) + outerColor = createIntentOption(audioWaveformCommandFields.outerColor) - splitChannels = Option.Boolean('--split-channels', { - description: - 'Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel.', - }) + style = createIntentOption(audioWaveformCommandFields.style) - zoom = Option.String('--zoom', { - description: - 'Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`.', - validator: t.isNumber(), - }) + splitChannels = createIntentOption(audioWaveformCommandFields.splitChannels) - pixelsPerSecond = Option.String('--pixels-per-second', { - description: - 'Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`.', - validator: t.isNumber(), - }) + zoom = createIntentOption(audioWaveformCommandFields.zoom) - bits = Option.String('--bits', { - description: 'Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16.', - validator: t.isNumber(), - }) + pixelsPerSecond = createIntentOption(audioWaveformCommandFields.pixelsPerSecond) - start = Option.String('--start', { - description: 'Available when style is `"v1"`. Start time in seconds.', - validator: t.isNumber(), - }) + bits = createIntentOption(audioWaveformCommandFields.bits) - end = Option.String('--end', { - description: 'Available when style is `"v1"`. End time in seconds (0 means end of audio).', - validator: t.isNumber(), - }) + start = createIntentOption(audioWaveformCommandFields.start) - colors = Option.String('--colors', { - description: - 'Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity".', - }) + end = createIntentOption(audioWaveformCommandFields.end) - borderColor = Option.String('--border-color', { - description: 'Available when style is `"v1"`. Border color in "rrggbbaa" format.', - }) + colors = createIntentOption(audioWaveformCommandFields.colors) - waveformStyle = Option.String('--waveform-style', { - description: 'Available when style is `"v1"`. Waveform style. Can be "normal" or "bars".', - }) + borderColor = createIntentOption(audioWaveformCommandFields.borderColor) - barWidth = Option.String('--bar-width', { - description: - 'Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars".', - validator: t.isNumber(), - }) + waveformStyle = createIntentOption(audioWaveformCommandFields.waveformStyle) - barGap = Option.String('--bar-gap', { - description: - 'Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars".', - validator: t.isNumber(), - }) + barWidth = createIntentOption(audioWaveformCommandFields.barWidth) - barStyle = Option.String('--bar-style', { - description: 'Available when style is `"v1"`. Bar style when waveform_style is "bars".', - }) + barGap = createIntentOption(audioWaveformCommandFields.barGap) - axisLabelColor = Option.String('--axis-label-color', { - description: 'Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format.', - }) + barStyle = createIntentOption(audioWaveformCommandFields.barStyle) - noAxisLabels = Option.Boolean('--no-axis-labels', { - description: - 'Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels.', - }) + axisLabelColor = createIntentOption(audioWaveformCommandFields.axisLabelColor) - withAxisLabels = Option.Boolean('--with-axis-labels', { - description: - 'Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels.', - }) + noAxisLabels = createIntentOption(audioWaveformCommandFields.noAxisLabels) - amplitudeScale = Option.String('--amplitude-scale', { - description: 'Available when style is `"v1"`. Amplitude scale factor.', - validator: t.isNumber(), - }) + withAxisLabels = createIntentOption(audioWaveformCommandFields.withAxisLabels) - compression = Option.String('--compression', { - description: - 'Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image".', - validator: t.isNumber(), - }) + amplitudeScale = createIntentOption(audioWaveformCommandFields.amplitudeScale) - protected override getIntentRawValues(): Record { - return { - ffmpeg: this.ffmpeg, - format: this.format, - width: this.width, - height: this.height, - antialiasing: this.antialiasing, - background_color: this.backgroundColor, - center_color: this.centerColor, - outer_color: this.outerColor, - style: this.style, - split_channels: this.splitChannels, - zoom: this.zoom, - pixels_per_second: this.pixelsPerSecond, - bits: this.bits, - start: this.start, - end: this.end, - colors: this.colors, - border_color: this.borderColor, - waveform_style: this.waveformStyle, - bar_width: this.barWidth, - bar_gap: this.barGap, - bar_style: this.barStyle, - axis_label_color: this.axisLabelColor, - no_axis_labels: this.noAxisLabels, - with_axis_labels: this.withAxisLabels, - amplitude_scale: this.amplitudeScale, - compression: this.compression, - } - } + compression = createIntentOption(audioWaveformCommandFields.compression) } class TextSpeakCommand extends GeneratedStandardFileIntentCommand { static override paths = [['text', 'speak']] + static override intentDefinition = textSpeakCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Speak text', @@ -1602,50 +2091,22 @@ class TextSpeakCommand extends GeneratedStandardFileIntentCommand { ], }) - protected override getIntentDefinition() { - return textSpeakCommandDefinition - } - - prompt = Option.String('--prompt', { - description: - 'Which text to speak. You can also set this to `null` and supply an input text file.', - }) - - provider = Option.String('--provider', { - description: - 'Which AI provider to leverage.\n\nTransloadit outsources this task and abstracts the interface so you can expect the same data structures, but different latencies and information being returned. Different cloud vendors have different areas they shine in, and we recommend to try out and see what yields the best results for your use case.', - required: true, - }) + prompt = createIntentOption(textSpeakCommandFields.prompt) - targetLanguage = Option.String('--target-language', { - description: - 'The written language of the document. This will also be the language of the spoken text.\n\nThe language should be specified in the [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) format, such as `"en-GB"`, `"de-DE"` or `"fr-FR"`. Please consult the list of supported languages and voices.', - }) + provider = createIntentOption(textSpeakCommandFields.provider) - voice = Option.String('--voice', { - description: - 'The gender to be used for voice synthesis. Please consult the list of supported languages and voices.', - }) + targetLanguage = createIntentOption(textSpeakCommandFields.targetLanguage) - ssml = Option.Boolean('--ssml', { - description: - 'Supply [Speech Synthesis Markup Language](https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language) instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations.\n\nPlease see the supported syntaxes for [AWS](https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html) and [GCP](https://cloud.google.com/text-to-speech/docs/ssml).', - }) + voice = createIntentOption(textSpeakCommandFields.voice) - protected override getIntentRawValues(): Record { - return { - prompt: this.prompt, - provider: this.provider, - target_language: this.targetLanguage, - voice: this.voice, - ssml: this.ssml, - } - } + ssml = createIntentOption(textSpeakCommandFields.ssml) } class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { static override paths = [['video', 'thumbs']] + static override intentDefinition = videoThumbsCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Extract thumbnails from videos', @@ -1653,82 +2114,32 @@ class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { examples: [['Run the command', 'transloadit video thumbs --input input.mp4 --out output/']], }) - protected override getIntentDefinition() { - return videoThumbsCommandDefinition - } - - ffmpeg = Option.String('--ffmpeg', { - description: - 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', - }) - - count = Option.String('--count', { - description: - 'The number of thumbnails to be extracted. As some videos have incorrect durations, the actual number of thumbnails generated may be less in rare cases. The maximum number of thumbnails we currently allow is 999.\n\nThe thumbnails are taken at regular intervals, determined by dividing the video duration by the count. For example, a count of 3 will produce thumbnails at 25%, 50% and 75% through the video.\n\nTo extract thumbnails for specific timestamps, use the `offsets` parameter.', - validator: t.isNumber(), - }) + ffmpeg = createIntentOption(videoThumbsCommandFields.ffmpeg) - offsets = Option.String('--offsets', { - description: - 'An array of offsets representing seconds of the file duration, such as `[ 2, 45, 120 ]`. Millisecond durations of a file can also be used by using decimal place values. For example, an offset from 1250 milliseconds would be represented with `1.25`. Offsets can also be percentage values such as `[ "2%", "50%", "75%" ]`.\n\nThis option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored.', - }) + count = createIntentOption(videoThumbsCommandFields.count) - format = Option.String('--format', { - description: - 'The format of the extracted thumbnail. Supported values are `"jpg"`, `"jpeg"` and `"png"`. Even if you specify the format to be `"jpeg"` the resulting thumbnails will have a `"jpg"` file extension.', - }) + offsets = createIntentOption(videoThumbsCommandFields.offsets) - width = Option.String('--width', { - description: - 'The width of the thumbnail, in pixels. Defaults to the original width of the video.', - validator: t.isNumber(), - }) + format = createIntentOption(videoThumbsCommandFields.format) - height = Option.String('--height', { - description: - 'The height of the thumbnail, in pixels. Defaults to the original height of the video.', - validator: t.isNumber(), - }) + width = createIntentOption(videoThumbsCommandFields.width) - resizeStrategy = Option.String('--resize-strategy', { - description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', - }) + height = createIntentOption(videoThumbsCommandFields.height) - background = Option.String('--background', { - description: - 'The background color of the resulting thumbnails in the `"rrggbbaa"` format (red, green, blue, alpha) when used with the `"pad"` resize strategy. The default color is black.', - }) + resizeStrategy = createIntentOption(videoThumbsCommandFields.resizeStrategy) - rotate = Option.String('--rotate', { - description: - 'Forces the video to be rotated by the specified degree integer. Currently, only multiples of 90 are supported. We automatically correct the orientation of many videos when the orientation is provided by the camera. This option is only useful for videos requiring rotation because it was not detected by the camera.', - validator: t.isNumber(), - }) + background = createIntentOption(videoThumbsCommandFields.background) - inputCodec = Option.String('--input-codec', { - description: - 'Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders.', - }) + rotate = createIntentOption(videoThumbsCommandFields.rotate) - protected override getIntentRawValues(): Record { - return { - ffmpeg: this.ffmpeg, - count: this.count, - offsets: this.offsets, - format: this.format, - width: this.width, - height: this.height, - resize_strategy: this.resizeStrategy, - background: this.background, - rotate: this.rotate, - input_codec: this.inputCodec, - } - } + inputCodec = createIntentOption(videoThumbsCommandFields.inputCodec) } class VideoEncodeHlsCommand extends GeneratedStandardFileIntentCommand { static override paths = [['video', 'encode-hls']] + static override intentDefinition = videoEncodeHlsCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Run builtin/encode-hls-video@latest', @@ -1736,19 +2147,13 @@ class VideoEncodeHlsCommand extends GeneratedStandardFileIntentCommand { 'Runs the `builtin/encode-hls-video@latest` template and writes the outputs to `--out`.', examples: [['Run the command', 'transloadit video encode-hls --input input.mp4 --out output/']], }) - - protected override getIntentDefinition() { - return videoEncodeHlsCommandDefinition - } - - protected override getIntentRawValues(): Record { - return {} - } } class FileCompressCommand extends GeneratedBundledFileIntentCommand { static override paths = [['file', 'compress']] + static override intentDefinition = fileCompressCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Compress files', @@ -1758,69 +2163,30 @@ class FileCompressCommand extends GeneratedBundledFileIntentCommand { ], }) - protected override getIntentDefinition() { - return fileCompressCommandDefinition - } - - format = Option.String('--format', { - description: - 'The format of the archive to be created. Supported values are `"tar"` and `"zip"`.\n\nNote that `"tar"` without setting `gzip` to `true` results in an archive that\'s not compressed in any way.', - }) - - gzip = Option.Boolean('--gzip', { - description: - 'Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format.', - }) + format = createIntentOption(fileCompressCommandFields.format) - password = Option.String('--password', { - description: - 'This allows you to encrypt all archive contents with a password and thereby protect it against unauthorized use. To unzip the archive, the user will need to provide the password in a text input field prompt.\n\nThis parameter has no effect if the format parameter is anything other than `"zip"`.', - }) + gzip = createIntentOption(fileCompressCommandFields.gzip) - compressionLevel = Option.String('--compression-level', { - description: - 'Determines how fiercely to try to compress the archive. `-0` is compressionless, which is suitable for media that is already compressed. `-1` is fastest with lowest compression. `-9` is slowest with the highest compression.\n\nIf you are using `-0` in combination with the `tar` format with `gzip` enabled, consider setting `gzip: false` instead. This results in a plain Tar archive, meaning it already has no compression.', - validator: t.isNumber(), - }) + password = createIntentOption(fileCompressCommandFields.password) - fileLayout = Option.String('--file-layout', { - description: - 'Determines if the result archive should contain all files in one directory (value for this is `"simple"`) or in subfolders according to the explanation below (value for this is `"advanced"`). The `"relative-path"` option preserves the relative directory structure of the input files.\n\nFiles with same names are numbered in the `"simple"` file layout to avoid naming collisions.', - }) + compressionLevel = createIntentOption(fileCompressCommandFields.compressionLevel) - archiveName = Option.String('--archive-name', { - description: 'The name of the archive file to be created (without the file extension).', - }) + fileLayout = createIntentOption(fileCompressCommandFields.fileLayout) - protected override getIntentRawValues(): Record { - return { - format: this.format, - gzip: this.gzip, - password: this.password, - compression_level: this.compressionLevel, - file_layout: this.fileLayout, - archive_name: this.archiveName, - } - } + archiveName = createIntentOption(fileCompressCommandFields.archiveName) } class FileDecompressCommand extends GeneratedStandardFileIntentCommand { static override paths = [['file', 'decompress']] + static override intentDefinition = fileDecompressCommandDefinition + static override usage = Command.Usage({ category: 'Intent Commands', description: 'Decompress archives', details: 'Runs `/file/decompress` on each input file and writes the results to `--out`.', examples: [['Run the command', 'transloadit file decompress --input input.file --out output/']], }) - - protected override getIntentDefinition() { - return fileDecompressCommandDefinition - } - - protected override getIntentRawValues(): Record { - return {} - } } export const intentCommands = [ diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 40b66c44..893589c3 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -1,6 +1,7 @@ import { statSync } from 'node:fs' import { basename } from 'node:path' import { Option } from 'clipanion' +import * as t from 'typanion' import type { z } from 'zod' import { prepareInputFiles } from '../inputFiles.ts' @@ -29,7 +30,7 @@ export interface PreparedIntentInputs { } export interface IntentSingleStepExecutionDefinition { - fieldSpecs: readonly IntentFieldSpec[] + fields: readonly IntentOptionDefinition[] fixedValues: Record kind: 'single-step' resultStepName: string @@ -61,6 +62,13 @@ export interface IntentNoInputCommandDefinition { outputRequired: boolean } +export interface IntentOptionDefinition extends IntentFieldSpec { + description?: string + optionFlags: string + propertyName: string + required?: boolean +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value) @@ -171,19 +179,19 @@ export async function prepareIntentInputs({ } export function parseIntentStep({ - fieldSpecs, + fields, fixedValues, rawValues, schema, }: { - fieldSpecs: readonly IntentFieldSpec[] + fields: readonly IntentFieldSpec[] fixedValues: Record rawValues: Record schema: TSchema }): z.input { const input: Record = { ...fixedValues } - for (const fieldSpec of fieldSpecs) { + for (const fieldSpec of fields) { const rawValue = rawValues[fieldSpec.name] if (rawValue == null) continue const fieldSchema = schema.shape[fieldSpec.name] @@ -193,7 +201,7 @@ export function parseIntentStep({ const parsed = schema.parse(input) as Record const normalizedInput: Record = { ...fixedValues } - for (const fieldSpec of fieldSpecs) { + for (const fieldSpec of fields) { const rawValue = rawValues[fieldSpec.name] if (rawValue == null) continue normalizedInput[fieldSpec.name] = parsed[fieldSpec.name] @@ -230,7 +238,7 @@ function createSingleStep( return parseIntentStep({ schema: execution.schema, fixedValues: resolveSingleStepFixedValues(execution, inputPolicy, hasInputs), - fieldSpecs: execution.fieldSpecs, + fields: execution.fields, rawValues, }) } @@ -246,7 +254,7 @@ function requiresLocalInput( return rawValues[inputPolicy.field] == null } -async function executeFileIntentCommand({ +async function executeIntentCommand({ client, definition, output, @@ -256,11 +264,13 @@ async function executeFileIntentCommand({ }: { client: AuthenticatedCommand['client'] createOptions: Omit - definition: IntentFileCommandDefinition + definition: IntentFileCommandDefinition | IntentNoInputCommandDefinition output: AuthenticatedCommand['output'] outputPath: string rawValues: Record }): Promise { + const inputPolicy: IntentInputPolicy = + 'inputPolicy' in definition ? definition.inputPolicy : { kind: 'required' } const executionOptions = definition.execution.kind === 'template' ? { @@ -270,7 +280,7 @@ async function executeFileIntentCommand({ stepsData: { [definition.execution.resultStepName]: createSingleStep( definition.execution, - definition.inputPolicy, + inputPolicy, rawValues, createOptions.inputs.length > 0, ), @@ -287,16 +297,21 @@ async function executeFileIntentCommand({ } abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { + declare static intentDefinition: IntentFileCommandDefinition | IntentNoInputCommandDefinition + outputPath = Option.String('--out,-o', { description: this.getOutputDescription(), required: true, }) - protected abstract getIntentDefinition(): - | IntentFileCommandDefinition - | IntentNoInputCommandDefinition + protected getIntentDefinition(): IntentFileCommandDefinition | IntentNoInputCommandDefinition { + const commandClass = this.constructor as unknown as typeof GeneratedIntentCommandBase + return commandClass.intentDefinition + } - protected abstract getIntentRawValues(): Record + protected getIntentRawValues(): Record { + return readIntentRawValues(this, getIntentOptionDefinitions(this.getIntentDefinition())) + } private getOutputDescription(): string { return this.getIntentDefinition().outputDescription @@ -304,30 +319,70 @@ abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { } export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentCommandBase { - protected abstract override getIntentDefinition(): IntentNoInputCommandDefinition - protected override async run(): Promise { - const intentDefinition = this.getIntentDefinition() - const step = createSingleStep( - intentDefinition.execution, - { kind: 'required' }, - this.getIntentRawValues(), - false, - ) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - inputs: [], - output: this.outputPath, - outputMode: intentDefinition.outputMode, - stepsData: { - [intentDefinition.execution.resultStepName]: step, - } as AssembliesCreateOptions['stepsData'], + return await executeIntentCommand({ + client: this.client, + createOptions: { + inputs: [], + }, + definition: this.getIntentDefinition() as IntentNoInputCommandDefinition, + output: this.output, + outputPath: this.outputPath, + rawValues: this.getIntentRawValues(), }) + } +} + +export function createIntentOption(fieldDefinition: IntentOptionDefinition): unknown { + const { description, kind, optionFlags, required } = fieldDefinition - return hasFailures ? 1 : undefined + if (kind === 'boolean') { + return Option.Boolean(optionFlags, { + description, + required, + }) } + + if (kind === 'number') { + return Option.String(optionFlags, { + description, + required, + validator: t.isNumber(), + }) + } + + return Option.String(optionFlags, { + description, + required, + }) +} + +export function getIntentOptionDefinitions( + definition: IntentFileCommandDefinition | IntentNoInputCommandDefinition, +): readonly IntentOptionDefinition[] { + if (definition.execution.kind !== 'single-step') { + return [] + } + + return definition.execution.fields } -abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase { +export function readIntentRawValues( + command: object, + fieldDefinitions: readonly IntentOptionDefinition[], +): Record { + const rawValues: Record = {} + + for (const fieldDefinition of fieldDefinitions) { + rawValues[fieldDefinition.name] = (command as Record)[ + fieldDefinition.propertyName + ] + } + + return rawValues +} + +export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase { inputs = inputPathsOption('Provide an input path, directory, URL, or - for stdin') inputBase64 = Option.Array('--input-base64', { @@ -340,7 +395,9 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase reprocessStale = reprocessStaleOption() - protected abstract override getIntentDefinition(): IntentFileCommandDefinition + protected override getIntentDefinition(): IntentFileCommandDefinition { + return super.getIntentDefinition() as IntentFileCommandDefinition + } protected async prepareInputs(): Promise { return await prepareIntentInputs({ @@ -408,7 +465,7 @@ abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase rawValues: Record, preparedInputs: PreparedIntentInputs, ): Promise { - return await executeFileIntentCommand({ + return await executeIntentCommand({ client: this.client, createOptions: this.getCreateOptions(preparedInputs.inputs), definition: this.getIntentDefinition(), From 4bd91b97fb225e360757430ec7a3996a5743287b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 08:19:28 +0200 Subject: [PATCH 25/44] refactor(node): simplify intent and output policy --- .../node/scripts/generate-intent-commands.ts | 3 +- packages/node/src/cli/commands/assemblies.ts | 130 +++++++++------ .../src/cli/commands/generated-intents.ts | 15 -- .../node/src/cli/intentResolvedDefinitions.ts | 21 --- packages/node/src/cli/intentRuntime.ts | 2 - packages/node/test/unit/cli/intents.test.ts | 148 +++++------------- 6 files changed, 120 insertions(+), 199 deletions(-) diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 4effd90a..bf3ad738 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -105,7 +105,7 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { : '' const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` - const outputLines = `\n outputDescription: ${JSON.stringify(spec.outputDescription)},\n outputRequired: ${JSON.stringify(spec.outputRequired)},` + const outputLines = `\n outputDescription: ${JSON.stringify(spec.outputDescription)},` const fieldsLine = spec.fieldSpecs.length === 0 ? '[]' : `Object.values(${formatFieldDefinitionsName(spec)})` @@ -126,7 +126,6 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { commandLabel: ${JSON.stringify(spec.commandLabel)}, inputPolicy: { "kind": "required" },${outputMode} outputDescription: ${JSON.stringify(spec.outputDescription)}, - outputRequired: ${JSON.stringify(spec.outputRequired)}, execution: { kind: 'template', templateId: ${JSON.stringify(spec.execution.templateId)}, diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 2a7cf155..52ca7e70 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -592,6 +592,19 @@ async function buildDirectoryDownloadTargets({ return targets } +function getSingleResultDownloadTarget( + allFiles: AssemblyResultFile[], + targetPath: string | null, +): AssemblyDownloadTarget[] { + const first = allFiles[0] + const resultUrl = first == null ? null : getResultFileUrl(first.file) + if (resultUrl == null) { + return [] + } + + return [{ resultUrl, targetPath }] +} + async function resolveResultDownloadTargets({ allFiles, entries, @@ -642,13 +655,7 @@ async function resolveResultDownloadTargets({ throw new Error('file outputs can only receive a single result file') } - const first = allFiles[0] - const resultUrl = first == null ? null : getResultFileUrl(first.file) - if (resultUrl == null) { - return [] - } - - return [{ resultUrl, targetPath: outputPath }] + return getSingleResultDownloadTarget(allFiles, outputPath) } if (singleAssembly) { @@ -668,9 +675,7 @@ async function resolveResultDownloadTargets({ } if (allFiles.length === 1) { - const first = allFiles[0] - const resultUrl = first == null ? null : getResultFileUrl(first.file) - return resultUrl == null ? [] : [{ resultUrl, targetPath: outputPath }] + return getSingleResultDownloadTarget(allFiles, outputPath) } return await buildDirectoryDownloadTargets({ @@ -680,6 +685,55 @@ async function resolveResultDownloadTargets({ }) } +async function shouldSkipStaleOutput({ + inputPaths, + outputPath, + outputPlanMtime, + outputRootIsDirectory, + reprocessStale, +}: { + inputPaths: string[] + outputPath: string | null + outputPlanMtime: Date + outputRootIsDirectory: boolean + reprocessStale?: boolean +}): Promise { + if (reprocessStale || outputPath == null || outputRootIsDirectory) { + return false + } + + if (inputPaths.length === 0 || inputPaths.some((inputPath) => inputPath === stdinWithPath.path)) { + return false + } + + const [outputErr, outputStat] = await tryCatch(fsp.stat(outputPath)) + if (outputErr != null || outputStat == null) { + return false + } + + if (inputPaths.length === 1) { + return outputStat.mtime > outputPlanMtime + } + + const inputStats = await Promise.all( + inputPaths.map(async (inputPath) => { + const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPath)) + if (inputErr != null || inputStat == null) { + return null + } + return inputStat + }), + ) + + if (inputStats.some((inputStat) => inputStat == null)) { + return false + } + + return inputStats.every((inputStat) => { + return inputStat != null && outputStat.mtime > inputStat.mtime + }) +} + async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, @@ -1336,12 +1390,15 @@ export async function create( if (!assembly.results) throw new Error('No results in assembly') if ( - !singleAssemblyMode && - outputPlan?.path != null && - !outputRootIsDirectory && - ((await tryCatch(fsp.stat(outputPlan.path)))[1]?.mtime ?? new Date(0)) > outputPlan.mtime + await shouldSkipStaleOutput({ + inputPaths, + outputPath: outputPlan?.path ?? null, + outputPlanMtime: outputPlan?.mtime ?? new Date(0), + outputRootIsDirectory, + reprocessStale, + }) ) { - outputctl.debug(`SKIPPED STALE RESULT ${inPath ?? 'null'} ${outputPlan.path}`) + outputctl.debug(`SKIPPED STALE RESULT ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) return assembly } @@ -1372,39 +1429,6 @@ export async function create( return assembly } - async function shouldSkipSingleAssemblyRun(inputPaths: string[]): Promise { - if (reprocessStale || resolvedOutput == null || outputRootIsDirectory) { - return false - } - - if (inputPaths.some((inputPath) => inputPath === stdinWithPath.path)) { - return false - } - - const [outputErr, outputStat] = await tryCatch(fsp.stat(resolvedOutput)) - if (outputErr != null || outputStat == null) { - return false - } - - const inputStats = await Promise.all( - inputPaths.map(async (inputPath) => { - const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPath)) - if (inputErr != null || inputStat == null) { - return null - } - return inputStat - }), - ) - - if (inputStats.some((inputStat) => inputStat == null)) { - return false - } - - return inputStats.every((inputStat) => { - return inputStat != null && outputStat.mtime > inputStat.mtime - }) - } - // Helper to process a single assembly job async function processAssemblyJob( inPath: string | null, @@ -1445,7 +1469,15 @@ export async function create( return } - if (await shouldSkipSingleAssemblyRun(collectedPaths)) { + if ( + await shouldSkipStaleOutput({ + inputPaths: collectedPaths, + outputPath: resolvedOutput ?? null, + outputPlanMtime: new Date(0), + outputRootIsDirectory, + reprocessStale, + }) + ) { outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`) resolve({ results: [], hasFailures: false }) return diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index a57511ee..c91adbca 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -1335,7 +1335,6 @@ const fileCompressCommandFields = { const imageGenerateCommandDefinition = { outputMode: 'file', outputDescription: 'Write the result to this path', - outputRequired: true, execution: { kind: 'single-step', schema: robotImageGenerateInstructionsSchema, @@ -1356,7 +1355,6 @@ const previewGenerateCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotFilePreviewInstructionsSchema, @@ -1377,7 +1375,6 @@ const imageRemoveBackgroundCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotImageBgremoveInstructionsSchema, @@ -1398,7 +1395,6 @@ const imageOptimizeCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotImageOptimizeInstructionsSchema, @@ -1419,7 +1415,6 @@ const imageResizeCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotImageResizeInstructionsSchema, @@ -1440,7 +1435,6 @@ const documentConvertCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentConvertInstructionsSchema, @@ -1461,7 +1455,6 @@ const documentOptimizeCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentOptimizeInstructionsSchema, @@ -1482,7 +1475,6 @@ const documentAutoRotateCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentAutorotateInstructionsSchema, @@ -1503,7 +1495,6 @@ const documentThumbsCommandDefinition = { }, outputMode: 'directory', outputDescription: 'Write the results to this directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotDocumentThumbsInstructionsSchema, @@ -1524,7 +1515,6 @@ const audioWaveformCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotAudioWaveformInstructionsSchema, @@ -1547,7 +1537,6 @@ const textSpeakCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotTextSpeakInstructionsSchema, @@ -1567,7 +1556,6 @@ const videoThumbsCommandDefinition = { }, outputMode: 'directory', outputDescription: 'Write the results to this directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotVideoThumbsInstructionsSchema, @@ -1586,7 +1574,6 @@ const videoEncodeHlsCommandDefinition = { inputPolicy: { kind: 'required' }, outputMode: 'directory', outputDescription: 'Write the results to this directory', - outputRequired: true, execution: { kind: 'template', templateId: 'builtin/encode-hls-video@latest', @@ -1600,7 +1587,6 @@ const fileCompressCommandDefinition = { }, outputMode: 'file', outputDescription: 'Write the result to this path or directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotFileCompressInstructionsSchema, @@ -1624,7 +1610,6 @@ const fileDecompressCommandDefinition = { }, outputMode: 'directory', outputDescription: 'Write the results to this directory', - outputRequired: true, execution: { kind: 'single-step', schema: robotFileDecompressInstructionsSchema, diff --git a/packages/node/src/cli/intentResolvedDefinitions.ts b/packages/node/src/cli/intentResolvedDefinitions.ts index bf9c2e41..86243612 100644 --- a/packages/node/src/cli/intentResolvedDefinitions.ts +++ b/packages/node/src/cli/intentResolvedDefinitions.ts @@ -21,16 +21,9 @@ export interface GeneratedSchemaField extends IntentFieldSpec { } export interface ResolvedIntentLocalFilesInput { - allowConcurrency?: boolean - allowSingleAssembly?: boolean - allowWatch?: boolean defaultSingleAssembly?: boolean - deleteAfterProcessing?: boolean - description: string inputPolicy: IntentInputPolicy kind: 'local-files' - recursive?: boolean - reprocessStale?: boolean } export interface ResolvedIntentNoneInput { @@ -71,7 +64,6 @@ export interface ResolvedIntentCommandSpec { input: ResolvedIntentInput outputDescription: string outputMode?: IntentOutputMode - outputRequired: boolean paths: string[] schemaSpec?: ResolvedIntentSchemaSpec } @@ -268,10 +260,6 @@ function inferInputSpecFromAnalysis({ if (defaultSingleAssembly) { return { kind: 'local-files', - description: 'Provide one or more input paths, directories, URLs, or - for stdin', - recursive: true, - deleteAfterProcessing: true, - reprocessStale: true, defaultSingleAssembly: true, inputPolicy, } @@ -279,13 +267,6 @@ function inferInputSpecFromAnalysis({ return { kind: 'local-files', - description: 'Provide an input path, directory, URL, or - for stdin', - recursive: true, - allowWatch: true, - deleteAfterProcessing: true, - reprocessStale: true, - allowSingleAssembly: true, - allowConcurrency: true, inputPolicy, } } @@ -469,7 +450,6 @@ function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedInte input: analysis.input, outputDescription: analysis.outputDescription, outputMode: analysis.outputMode, - outputRequired: true, paths: analysis.paths, schemaSpec: analysis.schemaSpec, } @@ -503,7 +483,6 @@ function resolveTemplateIntentSpec( ? 'Write the results to this directory' : 'Write the result to this path or directory', outputMode, - outputRequired: true, paths, } } diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 893589c3..8ea8dff6 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -52,14 +52,12 @@ export interface IntentFileCommandDefinition { inputPolicy: IntentInputPolicy outputDescription: string outputMode?: 'directory' | 'file' - outputRequired: boolean } export interface IntentNoInputCommandDefinition { execution: IntentSingleStepExecutionDefinition outputDescription: string outputMode?: 'directory' | 'file' - outputRequired: boolean } export interface IntentOptionDefinition extends IntentFieldSpec { diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index b80c254f..7d4671af 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -31,6 +31,26 @@ async function createTempDir(prefix: string): Promise { return tempDir } +async function runIntentCommand( + args: string[], + createResult: Awaited> = { + results: [], + hasFailures: false, + }, +): Promise<{ + createSpy: ReturnType> +}> { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue(createResult) + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main(args) + + return { createSpy } +} + function getIntentCommand(paths: string[]): (typeof intentCommands)[number] { const command = intentCommands.find((candidate) => { const candidatePaths = candidate.paths[0] @@ -70,17 +90,7 @@ afterEach(() => { describe('intent commands', () => { it('maps image generate flags to /image/generate step parameters', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'image', 'generate', '--prompt', @@ -114,17 +124,7 @@ describe('intent commands', () => { }) it('maps preview generate flags to /file/preview step parameters', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'preview', 'generate', '--input', @@ -161,18 +161,8 @@ describe('intent commands', () => { }) it('downloads URL inputs for preview generate before calling assemblies create', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - nock('https://example.com').get('/file.pdf').reply(200, 'pdf-data') - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'preview', 'generate', '--input', @@ -234,17 +224,7 @@ describe('intent commands', () => { }) it('supports base64 inputs for intent commands', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'document', 'convert', '--input-base64', @@ -300,17 +280,7 @@ describe('intent commands', () => { }) it('accepts native boolean flags for generated intent options', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'image', 'optimize', '--input', @@ -376,17 +346,15 @@ describe('intent commands', () => { }) it('maps video encode-hls to the builtin template', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main(['video', 'encode-hls', '--input', 'input.mp4', '--out', 'dist/hls', '--recursive']) + const { createSpy } = await runIntentCommand([ + 'video', + 'encode-hls', + '--input', + 'input.mp4', + '--out', + 'dist/hls', + '--recursive', + ]) expect(process.exitCode).toBeUndefined() expect(createSpy).toHaveBeenCalledWith( @@ -402,17 +370,7 @@ describe('intent commands', () => { }) it('maps text speak flags to /text/speak step parameters', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'text', 'speak', '--prompt', @@ -449,17 +407,7 @@ describe('intent commands', () => { }) it('supports prompt-only text speak runs without an input file', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'text', 'speak', '--prompt', @@ -490,17 +438,7 @@ describe('intent commands', () => { }) it('supports file-backed text speak runs without a prompt', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'text', 'speak', '--input', @@ -888,17 +826,7 @@ describe('intent commands', () => { }) it('maps file compress to a bundled single assembly by default', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'file', 'compress', '--input', From d9181f0f1eb0d60725ab5649e0fe0c612e60fd4e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 09:46:30 +0200 Subject: [PATCH 26/44] refactor(node): share intent and path helpers --- .../node/scripts/generate-intent-commands.ts | 4 +- packages/node/src/cli/commands/assemblies.ts | 45 +++-- packages/node/src/ensureUniqueCounter.ts | 22 +++ packages/node/src/inputFiles.ts | 27 +-- packages/node/test/unit/cli/intents.test.ts | 183 +++++------------- 5 files changed, 115 insertions(+), 166 deletions(-) create mode 100644 packages/node/src/ensureUniqueCounter.ts diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index bf3ad738..4c555c79 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -101,7 +101,7 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { : '' const inputPolicyLine = spec.input.kind === 'local-files' - ? `\n inputPolicy: ${JSON.stringify(spec.input.inputPolicy, null, 4).replace(/\n/g, '\n ')},` + ? `\n inputPolicy: ${JSON.stringify(spec.input.inputPolicy, null, 4).replaceAll('\n', '\n ')},` : '' const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` @@ -114,7 +114,7 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { kind: 'single-step', schema: ${spec.schemaSpec?.importName}, fields: ${fieldsLine}, - fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 4).replace(/\n/g, '\n ')}, + fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 4).replaceAll('\n', '\n ')}, resultStepName: ${JSON.stringify(spec.execution.resultStepName)}, }, } as const` diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 52ca7e70..82758c11 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -19,6 +19,7 @@ import { tryCatch } from '../../alphalib/tryCatch.ts' import type { Steps, StepsInput } from '../../alphalib/types/template.ts' import { stepsSchema } from '../../alphalib/types/template.ts' import type { CreateAssemblyParams, ReplayAssemblyParams } from '../../apiTypes.ts' +import { ensureUniqueCounterValue } from '../../ensureUniqueCounter.ts' import type { LintFatalLevel } from '../../lintAssemblyInstructions.ts' import { lintAssemblyInstructions } from '../../lintAssemblyInstructions.ts' import type { CreateAssemblyOptions, Transloadit } from '../../Transloadit.ts' @@ -395,6 +396,15 @@ function createOutputPlan(pathname: string | undefined, mtime: Date): OutputPlan } } +async function createExistingPathOutputPlan(outputPath: string | undefined): Promise { + if (outputPath == null) { + return createOutputPlan(undefined, new Date(0)) + } + + const [, stats] = await tryCatch(fsp.stat(outputPath)) + return createOutputPlan(outputPath, stats?.mtime ?? new Date(0)) +} + function dirProvider(output: string): OutputPlanProvider { return async (inpath, indir = process.cwd()) => { // Inputless assemblies can still write into a directory, but output paths are derived from @@ -409,21 +419,17 @@ function dirProvider(output: string): OutputPlanProvider { let relpath = path.relative(indir, inpath) relpath = relpath.replace(/^(\.\.\/)+/, '') const outpath = path.join(output, relpath) - const [, stats] = await tryCatch(fsp.stat(outpath)) - const mtime = stats?.mtime ?? new Date(0) - return createOutputPlan(outpath, mtime) + return await createExistingPathOutputPlan(outpath) } } function fileProvider(output: string): OutputPlanProvider { return async (_inpath) => { if (output === '-') { - return createOutputPlan(undefined, new Date(0)) + return await createExistingPathOutputPlan(undefined) } - const [, stats] = await tryCatch(fsp.stat(output)) - const mtime = stats?.mtime ?? new Date(0) - return createOutputPlan(output, mtime) + return await createExistingPathOutputPlan(output) } } @@ -513,20 +519,21 @@ function sanitizeResultName(value: string): string { async function ensureUniquePath(targetPath: string, reservedPaths: Set): Promise { const parsed = path.parse(targetPath) - let candidate = targetPath - let counter = 1 - while (true) { - if (!reservedPaths.has(candidate)) { - const [statErr] = await tryCatch(fsp.stat(candidate)) - if (statErr) { - reservedPaths.add(candidate) - return candidate + return await ensureUniqueCounterValue({ + initialValue: targetPath, + isTaken: async (candidate) => { + if (reservedPaths.has(candidate)) { + return true } - } - candidate = path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`) - counter += 1 - } + const [statErr] = await tryCatch(fsp.stat(candidate)) + return statErr == null + }, + reserve: (candidate) => { + reservedPaths.add(candidate) + }, + nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`), + }) } function flattenAssemblyResults(results: Record>): { diff --git a/packages/node/src/ensureUniqueCounter.ts b/packages/node/src/ensureUniqueCounter.ts new file mode 100644 index 00000000..43ff4f7b --- /dev/null +++ b/packages/node/src/ensureUniqueCounter.ts @@ -0,0 +1,22 @@ +export async function ensureUniqueCounterValue({ + initialValue, + isTaken, + reserve, + nextValue, +}: { + initialValue: T + isTaken: (candidate: T) => Promise | boolean + reserve: (candidate: T) => void + nextValue: (counter: number) => T +}): Promise { + let candidate = initialValue + let counter = 1 + + while (await isTaken(candidate)) { + candidate = nextValue(counter) + counter += 1 + } + + reserve(candidate) + return candidate +} diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index c77958d3..a27112f4 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -9,6 +9,7 @@ import { pipeline } from 'node:stream/promises' import got from 'got' import type { Input as IntoStreamInput } from 'into-stream' import type { CreateAssemblyParams } from './apiTypes.ts' +import { ensureUniqueCounterValue } from './ensureUniqueCounter.ts' export type InputFile = | { @@ -75,20 +76,20 @@ const ensureUniqueStepName = (baseName: string, used: Set): string => { return name } -const ensureUniqueTempFilePath = (root: string, filename: string, used: Set): string => { +const ensureUniqueTempFilePath = async ( + root: string, + filename: string, + used: Set, +): Promise => { const parsed = basename(filename) const extension = parsed.includes('.') ? `.${parsed.split('.').slice(1).join('.')}` : '' const stem = extension === '' ? parsed : parsed.slice(0, -extension.length) - - let candidate = join(root, parsed) - let counter = 1 - while (used.has(candidate)) { - candidate = join(root, `${stem}-${counter}${extension}`) - counter += 1 - } - - used.add(candidate) - return candidate + return await ensureUniqueCounterValue({ + initialValue: join(root, parsed), + isTaken: (candidate) => used.has(candidate), + reserve: (candidate) => used.add(candidate), + nextValue: (counter) => join(root, `${stem}-${counter}${extension}`), + }) } const decodeBase64 = (value: string): Buffer => Buffer.from(value, 'base64') @@ -301,7 +302,7 @@ export const prepareInputFiles = async ( if (base64Strategy === 'tempfile') { const root = await ensureTempRoot() const filename = file.filename ? basename(file.filename) : `${file.field}.bin` - const filePath = ensureUniqueTempFilePath(root, filename, usedTempPaths) + const filePath = await ensureUniqueTempFilePath(root, filename, usedTempPaths) await writeFile(filePath, buffer) files[file.field] = filePath } else { @@ -328,7 +329,7 @@ export const prepareInputFiles = async ( (file.filename ? basename(file.filename) : null) ?? getFilenameFromUrl(file.url) ?? `${file.field}.bin` - const filePath = ensureUniqueTempFilePath(root, filename, usedTempPaths) + const filePath = await ensureUniqueTempFilePath(root, filename, usedTempPaths) await downloadUrlToFile({ allowPrivateUrls, filePath, diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 7d4671af..50254959 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -469,17 +469,14 @@ describe('intent commands', () => { }) it('omits schema defaults from generated intent steps', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main(['audio', 'waveform', '--input', 'podcast.mp3', '--out', 'waveform.png']) + const { createSpy } = await runIntentCommand([ + 'audio', + 'waveform', + '--input', + 'podcast.mp3', + '--out', + 'waveform.png', + ]) expect(process.exitCode).toBeUndefined() expect(createSpy).toHaveBeenCalledWith( @@ -500,17 +497,7 @@ describe('intent commands', () => { }) it('applies schema normalization before submitting generated steps', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'audio', 'waveform', '--input', @@ -541,17 +528,14 @@ describe('intent commands', () => { }) it('passes directory output intent for multi-file commands', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main(['video', 'thumbs', '--input', 'demo.mp4', '--out', 'thumbs']) + const { createSpy } = await runIntentCommand([ + 'video', + 'thumbs', + '--input', + 'demo.mp4', + '--out', + 'thumbs', + ]) expect(process.exitCode).toBeUndefined() expect(createSpy).toHaveBeenCalledWith( @@ -566,17 +550,16 @@ describe('intent commands', () => { }) it('coerces numeric literal union options like video thumbs --rotate', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main(['video', 'thumbs', '--input', 'demo.mp4', '--rotate', '90', '--out', 'thumbs']) + const { createSpy } = await runIntentCommand([ + 'video', + 'thumbs', + '--input', + 'demo.mp4', + '--rotate', + '90', + '--out', + 'thumbs', + ]) expect(process.exitCode).toBeUndefined() expect(createSpy).toHaveBeenCalledWith( @@ -594,17 +577,7 @@ describe('intent commands', () => { }) it('maps array-valued robot parameters from JSON flags', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'video', 'thumbs', '--input', @@ -631,17 +604,7 @@ describe('intent commands', () => { }) it('maps object-valued robot parameters from JSON flags', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'preview', 'generate', '--input', @@ -675,17 +638,7 @@ describe('intent commands', () => { }) it('parses JSON objects for auto-typed flags like image resize --crop', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'image', 'resize', '--input', @@ -716,17 +669,7 @@ describe('intent commands', () => { }) it('parses JSON arrays for auto-typed flags like image resize --watermark-position', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'image', 'resize', '--input', @@ -752,17 +695,7 @@ describe('intent commands', () => { }) it('coerces mixed rotation flags like image resize --rotation 90', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'image', 'resize', '--input', @@ -789,17 +722,7 @@ describe('intent commands', () => { }) it('coerces mixed boolean-or-number flags like audio waveform --antialiasing 1', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main([ + const { createSpy } = await runIntentCommand([ 'audio', 'waveform', '--input', @@ -863,17 +786,16 @@ describe('intent commands', () => { }) it('omits nullable defaults like file compress password when not provided', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main(['file', 'compress', '--input', 'assets', '--format', 'zip', '--out', 'assets.zip']) + const { createSpy } = await runIntentCommand([ + 'file', + 'compress', + '--input', + 'assets', + '--format', + 'zip', + '--out', + 'assets.zip', + ]) expect(process.exitCode).toBeUndefined() expect(createSpy).toHaveBeenCalledWith( @@ -896,17 +818,14 @@ describe('intent commands', () => { }) it('omits numeric defaults like video thumbs rotate when not provided', async () => { - vi.stubEnv('TRANSLOADIT_KEY', 'key') - vi.stubEnv('TRANSLOADIT_SECRET', 'secret') - - const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ - results: [], - hasFailures: false, - }) - - vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) - - await main(['video', 'thumbs', '--input', 'demo.mp4', '--out', 'thumbs']) + const { createSpy } = await runIntentCommand([ + 'video', + 'thumbs', + '--input', + 'demo.mp4', + '--out', + 'thumbs', + ]) expect(process.exitCode).toBeUndefined() expect(createSpy).toHaveBeenCalledWith( From 1a1a9827060ed75babe1ebc81d1548823192c1e4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 11:41:04 +0200 Subject: [PATCH 27/44] feat(node): add image describe intent --- packages/node/scripts/test-intents-e2e.sh | 56 +++ packages/node/src/cli/commands/assemblies.ts | 15 +- .../node/src/cli/commands/image-describe.ts | 408 ++++++++++++++++++ packages/node/src/cli/commands/index.ts | 2 + packages/node/test/unit/cli/intents.test.ts | 113 +++++ 5 files changed, 590 insertions(+), 4 deletions(-) create mode 100644 packages/node/src/cli/commands/image-describe.ts diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index 62bec8e8..3dbc4328 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -97,6 +97,35 @@ verify_file_decompress() { grep -F 'Hello from Transloadit CLI intents' "$1/input.txt" >/dev/null } +verify_image_describe_labels() { + node --input-type=module <<'NODE' "$1" +import { readFileSync } from 'node:fs' + +const value = JSON.parse(readFileSync(process.argv[1], 'utf8')) +const ok = + Array.isArray(value) && + value.length > 0 && + value.every((item) => typeof item === 'string' || (item && typeof item.name === 'string')) + +process.exit(ok ? 0 : 1) +NODE +} + +verify_image_describe_wordpress() { + node --input-type=module <<'NODE' "$1" +import { readFileSync } from 'node:fs' + +const value = JSON.parse(readFileSync(process.argv[1], 'utf8')) +const required = ['altText', 'title', 'caption', 'description'] +const ok = + value && + typeof value === 'object' && + required.every((key) => typeof value[key] === 'string' && value[key].trim().length > 0) + +process.exit(ok ? 0 : 1) +NODE +} + verify_output() { local verifier="$1" local path="$2" @@ -111,6 +140,8 @@ verify_output() { video-thumbs) verify_video_thumbs "$path" ;; video-encode-hls) verify_video_encode_hls "$path" ;; file-decompress) verify_file_decompress "$path" ;; + image-describe-labels) verify_image_describe_labels "$path" ;; + image-describe-wordpress) verify_image_describe_wordpress "$path" ;; *) echo "Unknown verifier: $verifier" >&2 return 1 @@ -198,6 +229,31 @@ for (const smokeCase of intentSmokeCases) { smokeCase.verifier, ].join('\t')) } + +for (const smokeCase of [ + { + name: 'image-describe-labels', + paths: ['image', 'describe'], + args: ['--input', '@fixture/input.jpg', '--fields', 'labels'], + outputPath: 'image-describe-labels.json', + verifier: 'image-describe-labels', + }, + { + name: 'image-describe-wordpress', + paths: ['image', 'describe'], + args: ['--input', '@fixture/input.jpg', '--for', 'wordpress'], + outputPath: 'image-describe-wordpress.json', + verifier: 'image-describe-wordpress', + }, +]) { + console.log([ + smokeCase.name, + smokeCase.paths.join(' '), + smokeCase.args.join('\x1f'), + smokeCase.outputPath, + smokeCase.verifier, + ].join('\t')) +} NODE ) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 82758c11..5d2cf336 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -565,6 +565,12 @@ interface AssemblyDownloadTarget { targetPath: string | null } +const STALE_OUTPUT_GRACE_MS = 1000 + +function isMeaningfullyNewer(newer: Date, older: Date): boolean { + return newer.getTime() - older.getTime() > STALE_OUTPUT_GRACE_MS +} + async function buildDirectoryDownloadTargets({ allFiles, baseDir, @@ -719,7 +725,7 @@ async function shouldSkipStaleOutput({ } if (inputPaths.length === 1) { - return outputStat.mtime > outputPlanMtime + return isMeaningfullyNewer(outputStat.mtime, outputPlanMtime) } const inputStats = await Promise.all( @@ -737,7 +743,7 @@ async function shouldSkipStaleOutput({ } return inputStats.every((inputStat) => { - return inputStat != null && outputStat.mtime > inputStat.mtime + return inputStat != null && isMeaningfullyNewer(outputStat.mtime, inputStat.mtime) }) } @@ -1397,13 +1403,14 @@ export async function create( if (!assembly.results) throw new Error('No results in assembly') if ( - await shouldSkipStaleOutput({ + !singleAssemblyMode && + (await shouldSkipStaleOutput({ inputPaths, outputPath: outputPlan?.path ?? null, outputPlanMtime: outputPlan?.mtime ?? new Date(0), outputRootIsDirectory, reprocessStale, - }) + })) ) { outputctl.debug(`SKIPPED STALE RESULT ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) return assembly diff --git a/packages/node/src/cli/commands/image-describe.ts b/packages/node/src/cli/commands/image-describe.ts new file mode 100644 index 00000000..323f98f3 --- /dev/null +++ b/packages/node/src/cli/commands/image-describe.ts @@ -0,0 +1,408 @@ +import { statSync } from 'node:fs' +import { Command, Option } from 'clipanion' + +import type { InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/ai-chat.ts' +import type { InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/image-describe.ts' +import { + concurrencyOption, + countProvidedInputs, + deleteAfterProcessingOption, + inputPathsOption, + recursiveOption, + reprocessStaleOption, + validateSharedFileProcessingOptions, + watchOption, +} from '../fileProcessingOptions.ts' +import { prepareIntentInputs } from '../intentRuntime.ts' +import type { AssembliesCreateOptions } from './assemblies.ts' +import * as assembliesCommands from './assemblies.ts' +import { AuthenticatedCommand } from './BaseCommand.ts' + +const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const + +type ImageDescribeField = (typeof imageDescribeFields)[number] + +const wordpressDescribeFields = [ + 'altText', + 'title', + 'caption', + 'description', +] as const satisfies readonly ImageDescribeField[] + +const defaultDescribeModel = 'anthropic/claude-sonnet-4-5' + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +function parseFields(value: string[] | undefined): ImageDescribeField[] { + const rawFields = (value ?? []) + .flatMap((part) => part.split(',')) + .map((part) => part.trim()) + .filter(Boolean) + + if (rawFields.length === 0) { + return [] + } + + const fields: ImageDescribeField[] = [] + const seen = new Set() + + for (const rawField of rawFields) { + if (!imageDescribeFields.includes(rawField as ImageDescribeField)) { + throw new Error( + `Unsupported --fields value "${rawField}". Supported values: ${imageDescribeFields.join(', ')}`, + ) + } + + const field = rawField as ImageDescribeField + if (seen.has(field)) { + continue + } + + seen.add(field) + fields.push(field) + } + + return fields +} + +function resolveProfile(profile: string | undefined): 'wordpress' | null { + if (profile == null) { + return null + } + + if (profile === 'wordpress') { + return 'wordpress' + } + + throw new Error(`Unsupported --for value "${profile}". Supported values: wordpress`) +} + +function resolveRequestedFields({ + explicitFields, + profile, +}: { + explicitFields: ImageDescribeField[] + profile: 'wordpress' | null +}): ImageDescribeField[] { + if ( + explicitFields.length > 0 && + !(explicitFields.length === 1 && explicitFields[0] === 'labels') + ) { + return explicitFields + } + + if (profile === 'wordpress') { + return [...wordpressDescribeFields] + } + + return explicitFields.length === 0 ? ['labels'] : explicitFields +} + +function validateRequestedFields({ + explicitFields, + fields, + model, + profile, +}: { + explicitFields: ImageDescribeField[] + fields: ImageDescribeField[] + model: string + profile: 'wordpress' | null +}): void { + const includesLabels = fields.includes('labels') + + if (includesLabels && fields.length > 1) { + throw new Error( + 'The labels field cannot be combined with altText, title, caption, or description', + ) + } + + if (includesLabels && profile != null) { + throw new Error('--for cannot be combined with --fields labels') + } + + if (includesLabels && model !== defaultDescribeModel) { + throw new Error( + '--model is only supported when generating altText, title, caption, or description', + ) + } + + if (explicitFields.length === 0 && profile == null) { + return + } +} + +function buildAiChatSchema(fields: readonly ImageDescribeField[]): Record { + const properties = Object.fromEntries( + fields.map((field) => { + const description = + field === 'altText' + ? 'A concise accessibility-focused alt text that objectively describes the image' + : field === 'title' + ? 'A concise publishable title for the image' + : field === 'caption' + ? 'A short caption suitable for displaying below the image' + : 'A richer description of the image suitable for CMS usage' + + return [ + field, + { + type: 'string', + description, + }, + ] + }), + ) + + return { + type: 'object', + additionalProperties: false, + required: [...fields], + properties, + } +} + +function buildAiChatMessages({ + fields, + profile, +}: { + fields: readonly ImageDescribeField[] + profile: 'wordpress' | null +}): { + messages: string + systemMessage: string +} { + const requestedFields = fields.join(', ') + const profileHint = + profile === 'wordpress' + ? 'The output is for the WordPress media library.' + : 'The output is for a publishing workflow.' + + return { + systemMessage: [ + 'You generate accurate image copy for publishing workflows.', + profileHint, + 'Return only the schema fields requested.', + 'Be concrete, concise, and faithful to what is visibly present in the image.', + 'Do not invent facts, brands, locations, or identities that are not clearly visible.', + 'Avoid keyword stuffing, hype, and mentions of SEO or accessibility in the output itself.', + 'For altText, write one objective sentence focused on what matters to someone who cannot see the image.', + 'For title, keep it short and natural.', + 'For caption, write one short sentence suitable for publication.', + 'For description, write one or two sentences with slightly more context than the caption.', + ].join(' '), + messages: `Analyze the attached image and fill these fields: ${requestedFields}.`, + } +} + +function buildLabelStep(): InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput { + return { + robot: '/image/describe', + use: ':original', + result: true, + provider: 'aws', + format: 'json', + granularity: 'list', + explicit_descriptions: false, + } +} + +function buildAiChatStep({ + fields, + model, + profile, +}: { + fields: readonly ImageDescribeField[] + model: string + profile: 'wordpress' | null +}): InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput { + const { messages, systemMessage } = buildAiChatMessages({ fields, profile }) + + return { + robot: '/ai/chat', + use: ':original', + result: true, + model, + format: 'json', + return_messages: 'last', + test_credentials: true, + schema: JSON.stringify(buildAiChatSchema(fields)), + messages, + system_message: systemMessage, + // @TODO Move these inline /ai/chat instructions into a builtin template in api2 and + // switch this command to call that builtin instead of shipping prompt logic in the CLI. + } +} + +function buildDescribeStep({ + fields, + model, + profile, +}: { + fields: readonly ImageDescribeField[] + model: string + profile: 'wordpress' | null +}): + | InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput + | InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput { + if (fields.length === 1 && fields[0] === 'labels') { + return buildLabelStep() + } + + return buildAiChatStep({ fields, model, profile }) +} + +export class ImageDescribeCommand extends AuthenticatedCommand { + static override paths = [['image', 'describe']] + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Describe images as labels or publishable text fields', + details: + 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', + examples: [ + [ + 'Describe an image as labels', + 'transloadit image describe --input hero.jpg --out labels.json', + ], + [ + 'Generate WordPress-ready fields', + 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', + ], + [ + 'Request a custom field set', + 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', + ], + ], + }) + + outputPath = Option.String('--out,-o', { + description: 'Write the JSON result to this path or directory', + required: true, + }) + + inputs = inputPathsOption('Provide an input path, directory, URL, or - for stdin') + + inputBase64 = Option.Array('--input-base64', { + description: 'Provide base64-encoded input content directly', + }) + + fields = Option.Array('--fields', { + description: + 'Describe output fields to generate, for example labels or altText,title,caption,description', + }) + + forProfile = Option.String('--for', { + description: 'Use a named output profile, currently: wordpress', + }) + + model = Option.String('--model', defaultDescribeModel, { + description: `Model to use for generated text fields (default: ${defaultDescribeModel})`, + }) + + recursive = recursiveOption() + + deleteAfterProcessing = deleteAfterProcessingOption() + + reprocessStale = reprocessStaleOption() + + watch = watchOption() + + concurrency = concurrencyOption() + + private getProvidedInputCount(): number { + return countProvidedInputs({ + inputs: this.inputs, + inputBase64: this.inputBase64, + }) + } + + private hasTransientInputSources(): boolean { + return ( + (this.inputs?.some((input) => isHttpUrl(input)) ?? false) || + (this.inputBase64?.length ?? 0) > 0 + ) + } + + private isDirectoryOutputTarget(): boolean { + try { + return statSync(this.outputPath).isDirectory() + } catch { + return false + } + } + + protected override async run(): Promise { + if (this.getProvidedInputCount() === 0) { + this.output.error('image describe requires --input or --input-base64') + return 1 + } + + const sharedValidationError = validateSharedFileProcessingOptions({ + explicitInputCount: this.getProvidedInputCount(), + singleAssembly: false, + watch: this.watch, + watchRequiresInputsMessage: 'image describe --watch requires --input or --input-base64', + }) + if (sharedValidationError != null) { + this.output.error(sharedValidationError) + return 1 + } + + if (this.watch && this.hasTransientInputSources()) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } + + const explicitFields = parseFields(this.fields) + const profile = resolveProfile(this.forProfile) + const requestedFields = resolveRequestedFields({ explicitFields, profile }) + validateRequestedFields({ + explicitFields, + fields: requestedFields, + model: this.model, + profile, + }) + + const preparedInputs = await prepareIntentInputs({ + inputValues: this.inputs ?? [], + inputBase64Values: this.inputBase64 ?? [], + }) + + try { + if (this.watch && preparedInputs.hasTransientInputs) { + this.output.error('--watch is only supported for filesystem inputs') + return 1 + } + + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + del: this.deleteAfterProcessing, + inputs: preparedInputs.inputs, + recursive: this.recursive, + reprocessStale: this.reprocessStale, + watch: this.watch, + concurrency: this.concurrency, + output: this.outputPath, + outputMode: this.isDirectoryOutputTarget() ? 'directory' : 'file', + stepsData: { + describe: buildDescribeStep({ + fields: requestedFields, + model: this.model, + profile, + }), + } satisfies AssembliesCreateOptions['stepsData'], + }) + + return hasFailures ? 1 : undefined + } finally { + await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + } + } +} diff --git a/packages/node/src/cli/commands/index.ts b/packages/node/src/cli/commands/index.ts index 5abcbaf3..59723c83 100644 --- a/packages/node/src/cli/commands/index.ts +++ b/packages/node/src/cli/commands/index.ts @@ -16,6 +16,7 @@ import { SignatureCommand, SmartCdnSignatureCommand, TokenCommand } from './auth import { BillsGetCommand } from './bills.ts' import { DocsRobotsGetCommand, DocsRobotsListCommand } from './docs.ts' import { intentCommands } from './generated-intents.ts' +import { ImageDescribeCommand } from './image-describe.ts' import { NotificationsReplayCommand } from './notifications.ts' import { TemplatesCreateCommand, @@ -73,6 +74,7 @@ export function createCli(): Cli { cli.register(DocsRobotsGetCommand) // Intent-first commands + cli.register(ImageDescribeCommand) for (const command of intentCommands) { cli.register(command) } diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 50254959..677bf443 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -89,6 +89,119 @@ afterEach(() => { }) describe('intent commands', () => { + it('routes image describe labels through /image/describe', async () => { + const { createSpy } = await runIntentCommand([ + 'image', + 'describe', + '--input', + 'hero.jpg', + '--fields', + 'labels', + '--out', + 'labels.json', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['hero.jpg'], + output: 'labels.json', + stepsData: { + describe: expect.objectContaining({ + robot: '/image/describe', + use: ':original', + result: true, + provider: 'aws', + format: 'json', + granularity: 'list', + explicit_descriptions: false, + }), + }, + }), + ) + }) + + it('routes image describe --for wordpress through /ai/chat with a schema', async () => { + const { createSpy } = await runIntentCommand([ + 'image', + 'describe', + '--input', + 'hero.jpg', + '--for', + 'wordpress', + '--out', + 'fields.json', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['hero.jpg'], + output: 'fields.json', + stepsData: { + describe: expect.objectContaining({ + robot: '/ai/chat', + use: ':original', + result: true, + model: 'anthropic/claude-sonnet-4-5', + format: 'json', + return_messages: 'last', + test_credentials: true, + messages: expect.stringContaining('altText, title, caption, description'), + }), + }, + }), + ) + + const describeStep = createSpy.mock.calls[0]?.[2].stepsData?.describe + expect(describeStep).toBeDefined() + if (describeStep == null || typeof describeStep !== 'object') { + throw new Error('Missing describe step') + } + + const schema = JSON.parse(String((describeStep as Record).schema)) + expect(schema).toEqual({ + type: 'object', + additionalProperties: false, + required: ['altText', 'title', 'caption', 'description'], + properties: expect.objectContaining({ + altText: expect.objectContaining({ type: 'string' }), + title: expect.objectContaining({ type: 'string' }), + caption: expect.objectContaining({ type: 'string' }), + description: expect.objectContaining({ type: 'string' }), + }), + }) + }) + + it('rejects combining labels with authored image describe fields', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + }) + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'describe', + '--input', + 'hero.jpg', + '--fields', + 'labels,caption', + '--out', + 'fields.json', + ]) + + expect(process.exitCode).toBe(1) + expect(createSpy).not.toHaveBeenCalled() + }) + it('maps image generate flags to /image/generate step parameters', async () => { const { createSpy } = await runIntentCommand([ 'image', From a51b7a887300bf2551f3aba19c4b7439e83343e3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 11:53:12 +0200 Subject: [PATCH 28/44] fix(node): omit use from inputless intents --- packages/node/src/cli.ts | 6 +++--- packages/node/src/cli/commands/generated-intents.ts | 1 - packages/node/src/cli/intentResolvedDefinitions.ts | 10 ++++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/node/src/cli.ts b/packages/node/src/cli.ts index bdcd0b93..bf62dd17 100644 --- a/packages/node/src/cli.ts +++ b/packages/node/src/cli.ts @@ -32,13 +32,13 @@ export async function main(args = process.argv.slice(2)): Promise { } } -export function runCliWhenExecuted(): void { +export async function runCliWhenExecuted(): Promise { if (!shouldRunCli(process.argv[1])) return - void main().catch((error) => { + await main().catch((error) => { console.error((error as Error).message) process.exitCode = 1 }) } -runCliWhenExecuted() +await runCliWhenExecuted() diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index c91adbca..b234a951 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -1342,7 +1342,6 @@ const imageGenerateCommandDefinition = { fixedValues: { robot: '/image/generate', result: true, - use: ':original', }, resultStepName: 'generate', }, diff --git a/packages/node/src/cli/intentResolvedDefinitions.ts b/packages/node/src/cli/intentResolvedDefinitions.ts index 86243612..9e1df947 100644 --- a/packages/node/src/cli/intentResolvedDefinitions.ts +++ b/packages/node/src/cli/intentResolvedDefinitions.ts @@ -273,10 +273,12 @@ function inferInputSpecFromAnalysis({ function inferFixedValuesFromAnalysis({ defaultSingleAssembly, + inputMode, inputPolicy, robot, }: { defaultSingleAssembly?: boolean + inputMode: IntentInputMode inputPolicy: IntentInputPolicy robot: string }): Record { @@ -291,6 +293,13 @@ function inferFixedValuesFromAnalysis({ } } + if (inputMode === 'none') { + return { + robot, + result: true, + } + } + if (inputPolicy.kind === 'required') { return { robot, @@ -369,6 +378,7 @@ function analyzeRobotIntent(definition: RobotIntentDefinition): RobotIntentAnaly })(), fixedValues: inferFixedValuesFromAnalysis({ defaultSingleAssembly: definition.defaultSingleAssembly, + inputMode, inputPolicy, robot: definition.robot, }), From 6db8733847b26fa1106fac4039e2b03b5346c3e5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 13:22:36 +0200 Subject: [PATCH 29/44] refactor(node): share intent runtime helpers --- packages/node/src/cli/commands/assemblies.ts | 39 ++-- .../node/src/cli/commands/image-describe.ts | 188 +++++++----------- packages/node/src/cli/intentRuntime.ts | 71 ++++--- packages/node/src/inputFiles.ts | 19 +- 4 files changed, 145 insertions(+), 172 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 5d2cf336..57092d7a 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1458,8 +1458,14 @@ export async function create( }) } - if (singleAssembly) { - // Single-assembly mode: collect file paths, then create one assembly with all inputs + function handleEmitterError(err: Error): void { + abortController.abort() + queue.clear() + outputctl.error(err) + reject(err) + } + + function runSingleAssemblyEmitter(): void { const collectedPaths: string[] = [] emitter.on('job', (job: Job) => { @@ -1470,13 +1476,6 @@ export async function create( } }) - emitter.on('error', (err: Error) => { - abortController.abort() - queue.clear() - outputctl.error(err) - reject(err) - }) - emitter.on('end', async () => { if (collectedPaths.length === 0) { resolve({ results: [], hasFailures: false }) @@ -1533,13 +1532,13 @@ export async function create( resolve({ results, hasFailures }) }) - } else { - // Default mode: one assembly per file with p-queue concurrency limiting + } + + function runPerFileEmitter(): void { emitter.on('job', (job: Job) => { const inPath = job.inputPath const outputPlan = job.out outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) - // Add job to queue - p-queue handles concurrency automatically queue .add(async () => { const result = await processAssemblyJob(inPath, outputPlan) @@ -1553,19 +1552,19 @@ export async function create( }) }) - emitter.on('error', (err: Error) => { - abortController.abort() - queue.clear() - outputctl.error(err) - reject(err) - }) - emitter.on('end', async () => { - // Wait for all queued jobs to complete await queue.onIdle() resolve({ results, hasFailures }) }) } + + emitter.on('error', handleEmitterError) + + if (singleAssembly) { + runSingleAssemblyEmitter() + } else { + runPerFileEmitter() + } }) } diff --git a/packages/node/src/cli/commands/image-describe.ts b/packages/node/src/cli/commands/image-describe.ts index 323f98f3..6155c303 100644 --- a/packages/node/src/cli/commands/image-describe.ts +++ b/packages/node/src/cli/commands/image-describe.ts @@ -1,22 +1,11 @@ -import { statSync } from 'node:fs' import { Command, Option } from 'clipanion' +import { z } from 'zod' import type { InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/ai-chat.ts' import type { InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/image-describe.ts' -import { - concurrencyOption, - countProvidedInputs, - deleteAfterProcessingOption, - inputPathsOption, - recursiveOption, - reprocessStaleOption, - validateSharedFileProcessingOptions, - watchOption, -} from '../fileProcessingOptions.ts' -import { prepareIntentInputs } from '../intentRuntime.ts' -import type { AssembliesCreateOptions } from './assemblies.ts' +import type { IntentFileCommandDefinition, PreparedIntentInputs } from '../intentRuntime.ts' +import { GeneratedWatchableFileIntentCommand } from '../intentRuntime.ts' import * as assembliesCommands from './assemblies.ts' -import { AuthenticatedCommand } from './BaseCommand.ts' const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const @@ -31,15 +20,6 @@ const wordpressDescribeFields = [ const defaultDescribeModel = 'anthropic/claude-sonnet-4-5' -function isHttpUrl(value: string): boolean { - try { - const url = new URL(value) - return url.protocol === 'http:' || url.protocol === 'https:' - } catch { - return false - } -} - function parseFields(value: string[] | undefined): ImageDescribeField[] { const rawFields = (value ?? []) .flatMap((part) => part.split(',')) @@ -259,7 +239,27 @@ function buildDescribeStep({ return buildAiChatStep({ fields, model, profile }) } -export class ImageDescribeCommand extends AuthenticatedCommand { +const imageDescribeBaseDefinition = { + commandLabel: 'image describe', + execution: { + kind: 'single-step', + fields: [], + fixedValues: {}, + resultStepName: 'describe', + schema: z.object({}), + }, + inputPolicy: { + kind: 'required', + }, + outputDescription: 'Write the JSON result to this path or directory', +} satisfies IntentFileCommandDefinition + +type ResolvedDescribeRequest = { + profile: 'wordpress' | null + requestedFields: ImageDescribeField[] +} + +export class ImageDescribeCommand extends GeneratedWatchableFileIntentCommand { static override paths = [['image', 'describe']] static override usage = Command.Usage({ @@ -283,17 +283,6 @@ export class ImageDescribeCommand extends AuthenticatedCommand { ], }) - outputPath = Option.String('--out,-o', { - description: 'Write the JSON result to this path or directory', - required: true, - }) - - inputs = inputPathsOption('Provide an input path, directory, URL, or - for stdin') - - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) - fields = Option.Array('--fields', { description: 'Describe output fields to generate, for example labels or altText,title,caption,description', @@ -307,102 +296,65 @@ export class ImageDescribeCommand extends AuthenticatedCommand { description: `Model to use for generated text fields (default: ${defaultDescribeModel})`, }) - recursive = recursiveOption() - - deleteAfterProcessing = deleteAfterProcessingOption() - - reprocessStale = reprocessStaleOption() - - watch = watchOption() - - concurrency = concurrencyOption() - - private getProvidedInputCount(): number { - return countProvidedInputs({ - inputs: this.inputs, - inputBase64: this.inputBase64, - }) - } - - private hasTransientInputSources(): boolean { - return ( - (this.inputs?.some((input) => isHttpUrl(input)) ?? false) || - (this.inputBase64?.length ?? 0) > 0 - ) + protected override getIntentDefinition(): IntentFileCommandDefinition { + return imageDescribeBaseDefinition } - private isDirectoryOutputTarget(): boolean { - try { - return statSync(this.outputPath).isDirectory() - } catch { - return false + protected override getIntentRawValues(): Record { + return { + fields: this.fields, + forProfile: this.forProfile, + model: this.model, } } - protected override async run(): Promise { - if (this.getProvidedInputCount() === 0) { - this.output.error('image describe requires --input or --input-base64') - return 1 - } - - const sharedValidationError = validateSharedFileProcessingOptions({ - explicitInputCount: this.getProvidedInputCount(), - singleAssembly: false, - watch: this.watch, - watchRequiresInputsMessage: 'image describe --watch requires --input or --input-base64', - }) - if (sharedValidationError != null) { - this.output.error(sharedValidationError) - return 1 - } - - if (this.watch && this.hasTransientInputSources()) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - const explicitFields = parseFields(this.fields) - const profile = resolveProfile(this.forProfile) + private resolveDescribeRequest(rawValues: Record): ResolvedDescribeRequest { + const explicitFields = parseFields(rawValues.fields as string[] | undefined) + const profile = resolveProfile(rawValues.forProfile as string | undefined) const requestedFields = resolveRequestedFields({ explicitFields, profile }) validateRequestedFields({ explicitFields, fields: requestedFields, - model: this.model, + model: rawValues.model as string, profile, }) - const preparedInputs = await prepareIntentInputs({ - inputValues: this.inputs ?? [], - inputBase64Values: this.inputBase64 ?? [], - }) + return { + profile, + requestedFields, + } + } - try { - if (this.watch && preparedInputs.hasTransientInputs) { - this.output.error('--watch is only supported for filesystem inputs') - return 1 - } - - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - del: this.deleteAfterProcessing, - inputs: preparedInputs.inputs, - recursive: this.recursive, - reprocessStale: this.reprocessStale, - watch: this.watch, - concurrency: this.concurrency, - output: this.outputPath, - outputMode: this.isDirectoryOutputTarget() ? 'directory' : 'file', - stepsData: { - describe: buildDescribeStep({ - fields: requestedFields, - model: this.model, - profile, - }), - } satisfies AssembliesCreateOptions['stepsData'], - }) - - return hasFailures ? 1 : undefined - } finally { - await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup())) + protected override validateBeforePreparingInputs( + rawValues: Record, + ): number | undefined { + const validationError = super.validateBeforePreparingInputs(rawValues) + if (validationError != null) { + return validationError } + + this.resolveDescribeRequest(rawValues) + return undefined + } + + protected override async executePreparedInputs( + rawValues: Record, + preparedInputs: PreparedIntentInputs, + ): Promise { + const { profile, requestedFields } = this.resolveDescribeRequest(rawValues) + const { hasFailures } = await assembliesCommands.create(this.output, this.client, { + ...this.getCreateOptions(preparedInputs.inputs), + output: this.outputPath, + outputMode: this.resolveOutputMode(), + stepsData: { + describe: buildDescribeStep({ + fields: requestedFields, + model: rawValues.model as string, + profile, + }), + }, + }) + + return hasFailures ? 1 : undefined } } diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 8ea8dff6..0aeb7d9a 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -429,6 +429,22 @@ export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentComm ) } + protected resolveOutputMode(): 'directory' | 'file' | undefined { + if (this.getIntentDefinition().outputMode != null) { + return this.getIntentDefinition().outputMode + } + + try { + return statSync(this.outputPath).isDirectory() ? 'directory' : 'file' + } catch { + return 'file' + } + } + + protected isDirectoryOutputTarget(): boolean { + return this.resolveOutputMode() === 'directory' + } + protected validateInputPresence(rawValues: Record): number | undefined { const intentDefinition = this.getIntentDefinition() const inputCount = this.getProvidedInputCount() @@ -494,11 +510,9 @@ export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentComm } } -export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIntentCommandBase { +export abstract class GeneratedWatchableFileIntentCommand extends GeneratedFileIntentCommandBase { watch = watchOption() - singleAssembly = singleAssemblyOption() - concurrency = concurrencyOption() protected override getCreateOptions( @@ -507,7 +521,6 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn return { ...super.getCreateOptions(inputs), concurrency: this.concurrency, - singleAssembly: this.singleAssembly, watch: this.watch, } } @@ -522,7 +535,7 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn const sharedValidationError = validateSharedFileProcessingOptions({ explicitInputCount: this.getProvidedInputCount(), - singleAssembly: this.singleAssembly, + singleAssembly: false, watch: this.watch, watchRequiresInputsMessage: `${this.getIntentDefinition().commandLabel} --watch requires --input or --input-base64`, }) @@ -536,17 +549,6 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn return 1 } - if ( - this.singleAssembly && - this.getProvidedInputCount() > 1 && - !this.isDirectoryOutputTarget() - ) { - this.output.error( - 'Output must be a directory when using --single-assembly with multiple inputs', - ) - return 1 - } - return undefined } @@ -559,17 +561,40 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedFileIn } return undefined } +} + +export abstract class GeneratedStandardFileIntentCommand extends GeneratedWatchableFileIntentCommand { + singleAssembly = singleAssemblyOption() + + protected override getCreateOptions( + inputs: string[], + ): Omit { + return { + ...super.getCreateOptions(inputs), + singleAssembly: this.singleAssembly, + } + } - private isDirectoryOutputTarget(): boolean { - if (this.getIntentDefinition().outputMode === 'directory') { - return true + protected override validateBeforePreparingInputs( + rawValues: Record, + ): number | undefined { + const validationError = super.validateBeforePreparingInputs(rawValues) + if (validationError != null) { + return validationError } - try { - return statSync(this.outputPath).isDirectory() - } catch { - return false + if ( + this.singleAssembly && + this.getProvidedInputCount() > 1 && + !this.isDirectoryOutputTarget() + ) { + this.output.error( + 'Output must be a directory when using --single-assembly with multiple inputs', + ) + return 1 } + + return undefined } } diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index a27112f4..9e7b6cf9 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -65,16 +65,13 @@ const ensureUnique = (field: string, used: Set): void => { used.add(field) } -const ensureUniqueStepName = (baseName: string, used: Set): string => { - let name = baseName - let counter = 1 - while (used.has(name)) { - name = `${baseName}_${counter}` - counter += 1 - } - used.add(name) - return name -} +const ensureUniqueStepName = async (baseName: string, used: Set): Promise => + await ensureUniqueCounterValue({ + initialValue: baseName, + isTaken: (candidate) => used.has(candidate), + reserve: (candidate) => used.add(candidate), + nextValue: (counter) => `${baseName}_${counter}`, + }) const ensureUniqueTempFilePath = async ( root: string, @@ -317,7 +314,7 @@ export const prepareInputFiles = async ( urlStrategy === 'import' || (urlStrategy === 'import-if-present' && targetStep) if (shouldImport) { - const stepName = targetStep ?? ensureUniqueStepName(file.field, usedSteps) + const stepName = targetStep ?? (await ensureUniqueStepName(file.field, usedSteps)) const urls = importUrlsByStep.get(stepName) ?? [] urls.push(file.url) importUrlsByStep.set(stepName, urls) From 44e10b475865b918a03c605d2d625a00a4802a35 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 14:09:59 +0200 Subject: [PATCH 30/44] refactor(node): generate image describe intent --- .../node/scripts/generate-intent-commands.ts | 57 ++- .../src/cli/commands/generated-intents.ts | 74 ++++ .../node/src/cli/commands/image-describe.ts | 360 ------------------ packages/node/src/cli/commands/index.ts | 2 - packages/node/src/cli/intentCommandSpecs.ts | 26 +- packages/node/src/cli/intentFields.ts | 10 +- .../node/src/cli/intentResolvedDefinitions.ts | 92 +++++ packages/node/src/cli/intentRuntime.ts | 270 ++++++++++++- packages/node/src/cli/intentSmokeCases.ts | 5 + 9 files changed, 509 insertions(+), 387 deletions(-) delete mode 100644 packages/node/src/cli/commands/image-describe.ts diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts index 4c555c79..356f0d2d 100644 --- a/packages/node/scripts/generate-intent-commands.ts +++ b/packages/node/scripts/generate-intent-commands.ts @@ -28,21 +28,24 @@ function formatFieldDefinitionsName(spec: ResolvedIntentCommandSpec): string { return `${spec.className[0]?.toLowerCase() ?? ''}${spec.className.slice(1)}Fields` } -function formatSchemaFields( - fieldSpecs: GeneratedSchemaField[], - spec: ResolvedIntentCommandSpec, -): string { - return fieldSpecs +function getOptionFields(spec: ResolvedIntentCommandSpec): readonly GeneratedSchemaField[] { + if (spec.execution.kind === 'dynamic-step') { + return spec.execution.fields + } + + return spec.fieldSpecs +} + +function formatSchemaFields(spec: ResolvedIntentCommandSpec): string { + return getOptionFields(spec) .map((fieldSpec) => { return ` ${fieldSpec.propertyName} = createIntentOption(${formatFieldDefinitionsName(spec)}.${fieldSpec.propertyName})` }) .join('\n\n') } -function formatFieldDefinitions( - fieldSpecs: GeneratedSchemaField[], - spec: ResolvedIntentCommandSpec, -): string { +function formatFieldDefinitions(spec: ResolvedIntentCommandSpec): string { + const fieldSpecs = getOptionFields(spec) if (fieldSpecs.length === 0) { return '' } @@ -82,14 +85,18 @@ function getCommandDefinitionName(spec: ResolvedIntentCommandSpec): string { } function getBaseClassName(spec: ResolvedIntentCommandSpec): string { - if (spec.input.kind === 'none') { + if (spec.runnerKind === 'no-input') { return 'GeneratedNoInputIntentCommand' } - if (spec.input.defaultSingleAssembly) { + if (spec.runnerKind === 'bundled') { return 'GeneratedBundledFileIntentCommand' } + if (spec.runnerKind === 'watchable') { + return 'GeneratedWatchableFileIntentCommand' + } + return 'GeneratedStandardFileIntentCommand' } @@ -120,6 +127,29 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { } as const` } + if (spec.execution.kind === 'dynamic-step') { + const commandLabelLine = + spec.input.kind === 'local-files' + ? `\n commandLabel: ${JSON.stringify(spec.commandLabel)},` + : '' + const inputPolicyLine = + spec.input.kind === 'local-files' + ? `\n inputPolicy: ${JSON.stringify(spec.input.inputPolicy, null, 4).replaceAll('\n', '\n ')},` + : '' + const outputMode = + spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` + + return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${inputPolicyLine}${outputMode} + outputDescription: ${JSON.stringify(spec.outputDescription)}, + execution: { + kind: 'dynamic-step', + handler: ${JSON.stringify(spec.execution.handler)}, + fields: Object.values(${formatFieldDefinitionsName(spec)}), + resultStepName: ${JSON.stringify(spec.execution.resultStepName)}, + }, +} as const` + } + const outputMode = spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` return `const ${getCommandDefinitionName(spec)} = { @@ -134,7 +164,7 @@ function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { } function generateClass(spec: ResolvedIntentCommandSpec): string { - const schemaFields = formatSchemaFields(spec.fieldSpecs, spec) + const schemaFields = formatSchemaFields(spec) const baseClassName = getBaseClassName(spec) return ` @@ -159,7 +189,7 @@ ${schemaFields} function generateFile(specs: ResolvedIntentCommandSpec[]): string { const fieldDefinitions = specs - .map((spec) => formatFieldDefinitions(spec.fieldSpecs, spec)) + .map((spec) => formatFieldDefinitions(spec)) .filter((definition) => definition.length > 0) const commandDefinitions = specs.map(formatIntentDefinition) const commandClasses = specs.map(generateClass) @@ -176,6 +206,7 @@ import { GeneratedBundledFileIntentCommand, GeneratedNoInputIntentCommand, GeneratedStandardFileIntentCommand, + GeneratedWatchableFileIntentCommand, } from '../intentRuntime.ts' ${fieldDefinitions.join('\n\n')} ${commandDefinitions.join('\n\n')} diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts index b234a951..70a61a34 100644 --- a/packages/node/src/cli/commands/generated-intents.ts +++ b/packages/node/src/cli/commands/generated-intents.ts @@ -22,6 +22,7 @@ import { GeneratedBundledFileIntentCommand, GeneratedNoInputIntentCommand, GeneratedStandardFileIntentCommand, + GeneratedWatchableFileIntentCommand, } from '../intentRuntime.ts' const imageGenerateCommandFields = { @@ -1283,6 +1284,31 @@ const videoThumbsCommandFields = { }, } as const +const imageDescribeCommandFields = { + fields: { + name: 'fields', + kind: 'string-array', + propertyName: 'fields', + optionFlags: '--fields', + description: + 'Describe output fields to generate, for example labels or altText,title,caption,description', + }, + forProfile: { + name: 'forProfile', + kind: 'string', + propertyName: 'forProfile', + optionFlags: '--for', + description: 'Use a named output profile, currently: wordpress', + }, + model: { + name: 'model', + kind: 'string', + propertyName: 'model', + optionFlags: '--model', + description: 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', + }, +} as const + const fileCompressCommandFields = { format: { name: 'format', @@ -1579,6 +1605,20 @@ const videoEncodeHlsCommandDefinition = { }, } as const +const imageDescribeCommandDefinition = { + commandLabel: 'image describe', + inputPolicy: { + kind: 'required', + }, + outputDescription: 'Write the JSON result to this path or directory', + execution: { + kind: 'dynamic-step', + handler: 'image-describe', + fields: Object.values(imageDescribeCommandFields), + resultStepName: 'describe', + }, +} as const + const fileCompressCommandDefinition = { commandLabel: 'file compress', inputPolicy: { @@ -2133,6 +2173,39 @@ class VideoEncodeHlsCommand extends GeneratedStandardFileIntentCommand { }) } +class ImageDescribeCommand extends GeneratedWatchableFileIntentCommand { + static override paths = [['image', 'describe']] + + static override intentDefinition = imageDescribeCommandDefinition + + static override usage = Command.Usage({ + category: 'Intent Commands', + description: 'Describe images as labels or publishable text fields', + details: + 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', + examples: [ + [ + 'Describe an image as labels', + 'transloadit image describe --input hero.jpg --out labels.json', + ], + [ + 'Generate WordPress-ready fields', + 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', + ], + [ + 'Request a custom field set', + 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', + ], + ], + }) + + fields = createIntentOption(imageDescribeCommandFields.fields) + + forProfile = createIntentOption(imageDescribeCommandFields.forProfile) + + model = createIntentOption(imageDescribeCommandFields.model) +} + class FileCompressCommand extends GeneratedBundledFileIntentCommand { static override paths = [['file', 'compress']] @@ -2187,6 +2260,7 @@ export const intentCommands = [ TextSpeakCommand, VideoThumbsCommand, VideoEncodeHlsCommand, + ImageDescribeCommand, FileCompressCommand, FileDecompressCommand, ] as const diff --git a/packages/node/src/cli/commands/image-describe.ts b/packages/node/src/cli/commands/image-describe.ts deleted file mode 100644 index 6155c303..00000000 --- a/packages/node/src/cli/commands/image-describe.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { Command, Option } from 'clipanion' -import { z } from 'zod' - -import type { InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/ai-chat.ts' -import type { InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/image-describe.ts' -import type { IntentFileCommandDefinition, PreparedIntentInputs } from '../intentRuntime.ts' -import { GeneratedWatchableFileIntentCommand } from '../intentRuntime.ts' -import * as assembliesCommands from './assemblies.ts' - -const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const - -type ImageDescribeField = (typeof imageDescribeFields)[number] - -const wordpressDescribeFields = [ - 'altText', - 'title', - 'caption', - 'description', -] as const satisfies readonly ImageDescribeField[] - -const defaultDescribeModel = 'anthropic/claude-sonnet-4-5' - -function parseFields(value: string[] | undefined): ImageDescribeField[] { - const rawFields = (value ?? []) - .flatMap((part) => part.split(',')) - .map((part) => part.trim()) - .filter(Boolean) - - if (rawFields.length === 0) { - return [] - } - - const fields: ImageDescribeField[] = [] - const seen = new Set() - - for (const rawField of rawFields) { - if (!imageDescribeFields.includes(rawField as ImageDescribeField)) { - throw new Error( - `Unsupported --fields value "${rawField}". Supported values: ${imageDescribeFields.join(', ')}`, - ) - } - - const field = rawField as ImageDescribeField - if (seen.has(field)) { - continue - } - - seen.add(field) - fields.push(field) - } - - return fields -} - -function resolveProfile(profile: string | undefined): 'wordpress' | null { - if (profile == null) { - return null - } - - if (profile === 'wordpress') { - return 'wordpress' - } - - throw new Error(`Unsupported --for value "${profile}". Supported values: wordpress`) -} - -function resolveRequestedFields({ - explicitFields, - profile, -}: { - explicitFields: ImageDescribeField[] - profile: 'wordpress' | null -}): ImageDescribeField[] { - if ( - explicitFields.length > 0 && - !(explicitFields.length === 1 && explicitFields[0] === 'labels') - ) { - return explicitFields - } - - if (profile === 'wordpress') { - return [...wordpressDescribeFields] - } - - return explicitFields.length === 0 ? ['labels'] : explicitFields -} - -function validateRequestedFields({ - explicitFields, - fields, - model, - profile, -}: { - explicitFields: ImageDescribeField[] - fields: ImageDescribeField[] - model: string - profile: 'wordpress' | null -}): void { - const includesLabels = fields.includes('labels') - - if (includesLabels && fields.length > 1) { - throw new Error( - 'The labels field cannot be combined with altText, title, caption, or description', - ) - } - - if (includesLabels && profile != null) { - throw new Error('--for cannot be combined with --fields labels') - } - - if (includesLabels && model !== defaultDescribeModel) { - throw new Error( - '--model is only supported when generating altText, title, caption, or description', - ) - } - - if (explicitFields.length === 0 && profile == null) { - return - } -} - -function buildAiChatSchema(fields: readonly ImageDescribeField[]): Record { - const properties = Object.fromEntries( - fields.map((field) => { - const description = - field === 'altText' - ? 'A concise accessibility-focused alt text that objectively describes the image' - : field === 'title' - ? 'A concise publishable title for the image' - : field === 'caption' - ? 'A short caption suitable for displaying below the image' - : 'A richer description of the image suitable for CMS usage' - - return [ - field, - { - type: 'string', - description, - }, - ] - }), - ) - - return { - type: 'object', - additionalProperties: false, - required: [...fields], - properties, - } -} - -function buildAiChatMessages({ - fields, - profile, -}: { - fields: readonly ImageDescribeField[] - profile: 'wordpress' | null -}): { - messages: string - systemMessage: string -} { - const requestedFields = fields.join(', ') - const profileHint = - profile === 'wordpress' - ? 'The output is for the WordPress media library.' - : 'The output is for a publishing workflow.' - - return { - systemMessage: [ - 'You generate accurate image copy for publishing workflows.', - profileHint, - 'Return only the schema fields requested.', - 'Be concrete, concise, and faithful to what is visibly present in the image.', - 'Do not invent facts, brands, locations, or identities that are not clearly visible.', - 'Avoid keyword stuffing, hype, and mentions of SEO or accessibility in the output itself.', - 'For altText, write one objective sentence focused on what matters to someone who cannot see the image.', - 'For title, keep it short and natural.', - 'For caption, write one short sentence suitable for publication.', - 'For description, write one or two sentences with slightly more context than the caption.', - ].join(' '), - messages: `Analyze the attached image and fill these fields: ${requestedFields}.`, - } -} - -function buildLabelStep(): InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput { - return { - robot: '/image/describe', - use: ':original', - result: true, - provider: 'aws', - format: 'json', - granularity: 'list', - explicit_descriptions: false, - } -} - -function buildAiChatStep({ - fields, - model, - profile, -}: { - fields: readonly ImageDescribeField[] - model: string - profile: 'wordpress' | null -}): InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput { - const { messages, systemMessage } = buildAiChatMessages({ fields, profile }) - - return { - robot: '/ai/chat', - use: ':original', - result: true, - model, - format: 'json', - return_messages: 'last', - test_credentials: true, - schema: JSON.stringify(buildAiChatSchema(fields)), - messages, - system_message: systemMessage, - // @TODO Move these inline /ai/chat instructions into a builtin template in api2 and - // switch this command to call that builtin instead of shipping prompt logic in the CLI. - } -} - -function buildDescribeStep({ - fields, - model, - profile, -}: { - fields: readonly ImageDescribeField[] - model: string - profile: 'wordpress' | null -}): - | InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput - | InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput { - if (fields.length === 1 && fields[0] === 'labels') { - return buildLabelStep() - } - - return buildAiChatStep({ fields, model, profile }) -} - -const imageDescribeBaseDefinition = { - commandLabel: 'image describe', - execution: { - kind: 'single-step', - fields: [], - fixedValues: {}, - resultStepName: 'describe', - schema: z.object({}), - }, - inputPolicy: { - kind: 'required', - }, - outputDescription: 'Write the JSON result to this path or directory', -} satisfies IntentFileCommandDefinition - -type ResolvedDescribeRequest = { - profile: 'wordpress' | null - requestedFields: ImageDescribeField[] -} - -export class ImageDescribeCommand extends GeneratedWatchableFileIntentCommand { - static override paths = [['image', 'describe']] - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Describe images as labels or publishable text fields', - details: - 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', - examples: [ - [ - 'Describe an image as labels', - 'transloadit image describe --input hero.jpg --out labels.json', - ], - [ - 'Generate WordPress-ready fields', - 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', - ], - [ - 'Request a custom field set', - 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', - ], - ], - }) - - fields = Option.Array('--fields', { - description: - 'Describe output fields to generate, for example labels or altText,title,caption,description', - }) - - forProfile = Option.String('--for', { - description: 'Use a named output profile, currently: wordpress', - }) - - model = Option.String('--model', defaultDescribeModel, { - description: `Model to use for generated text fields (default: ${defaultDescribeModel})`, - }) - - protected override getIntentDefinition(): IntentFileCommandDefinition { - return imageDescribeBaseDefinition - } - - protected override getIntentRawValues(): Record { - return { - fields: this.fields, - forProfile: this.forProfile, - model: this.model, - } - } - - private resolveDescribeRequest(rawValues: Record): ResolvedDescribeRequest { - const explicitFields = parseFields(rawValues.fields as string[] | undefined) - const profile = resolveProfile(rawValues.forProfile as string | undefined) - const requestedFields = resolveRequestedFields({ explicitFields, profile }) - validateRequestedFields({ - explicitFields, - fields: requestedFields, - model: rawValues.model as string, - profile, - }) - - return { - profile, - requestedFields, - } - } - - protected override validateBeforePreparingInputs( - rawValues: Record, - ): number | undefined { - const validationError = super.validateBeforePreparingInputs(rawValues) - if (validationError != null) { - return validationError - } - - this.resolveDescribeRequest(rawValues) - return undefined - } - - protected override async executePreparedInputs( - rawValues: Record, - preparedInputs: PreparedIntentInputs, - ): Promise { - const { profile, requestedFields } = this.resolveDescribeRequest(rawValues) - const { hasFailures } = await assembliesCommands.create(this.output, this.client, { - ...this.getCreateOptions(preparedInputs.inputs), - output: this.outputPath, - outputMode: this.resolveOutputMode(), - stepsData: { - describe: buildDescribeStep({ - fields: requestedFields, - model: rawValues.model as string, - profile, - }), - }, - }) - - return hasFailures ? 1 : undefined - } -} diff --git a/packages/node/src/cli/commands/index.ts b/packages/node/src/cli/commands/index.ts index 59723c83..5abcbaf3 100644 --- a/packages/node/src/cli/commands/index.ts +++ b/packages/node/src/cli/commands/index.ts @@ -16,7 +16,6 @@ import { SignatureCommand, SmartCdnSignatureCommand, TokenCommand } from './auth import { BillsGetCommand } from './bills.ts' import { DocsRobotsGetCommand, DocsRobotsListCommand } from './docs.ts' import { intentCommands } from './generated-intents.ts' -import { ImageDescribeCommand } from './image-describe.ts' import { NotificationsReplayCommand } from './notifications.ts' import { TemplatesCreateCommand, @@ -74,7 +73,6 @@ export function createCli(): Cli { cli.register(DocsRobotsGetCommand) // Intent-first commands - cli.register(ImageDescribeCommand) for (const command of intentCommands) { cli.register(command) } diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index b9908b32..1b7dbf5d 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -84,7 +84,16 @@ export interface TemplateIntentDefinition extends IntentBaseDefinition { templateId: string } -export type IntentDefinition = RobotIntentDefinition | TemplateIntentDefinition +export interface SemanticIntentDefinition extends IntentBaseDefinition { + kind: 'semantic' + paths: string[] + semantic: 'image-describe' +} + +export type IntentDefinition = + | RobotIntentDefinition + | TemplateIntentDefinition + | SemanticIntentDefinition const commandPathAliases = new Map([ ['autorotate', 'auto-rotate'], @@ -99,12 +108,20 @@ function defineTemplateIntent(definition: TemplateIntentDefinition): TemplateInt return definition } +function defineSemanticIntent(definition: SemanticIntentDefinition): SemanticIntentDefinition { + return definition +} + export function getIntentCatalogKey(definition: IntentDefinition): string { if (definition.kind === 'robot') { return definition.robot } - return definition.templateId + if (definition.kind === 'template') { + return definition.templateId + } + + return `${definition.semantic}:${definition.paths.join('/')}` } export function getIntentPaths(definition: IntentDefinition): string[] { @@ -237,6 +254,11 @@ export const intentCatalog = [ paths: ['video', 'encode-hls'], outputMode: 'directory', }), + defineSemanticIntent({ + kind: 'semantic', + semantic: 'image-describe', + paths: ['image', 'describe'], + }), defineRobotIntent({ kind: 'robot', robot: '/file/compress', diff --git a/packages/node/src/cli/intentFields.ts b/packages/node/src/cli/intentFields.ts index de572fa3..5a54da5b 100644 --- a/packages/node/src/cli/intentFields.ts +++ b/packages/node/src/cli/intentFields.ts @@ -11,7 +11,7 @@ import { ZodUnion, } from 'zod' -export type IntentFieldKind = 'auto' | 'boolean' | 'json' | 'number' | 'string' +export type IntentFieldKind = 'auto' | 'boolean' | 'json' | 'number' | 'string' | 'string-array' export interface IntentFieldSpec { kind: IntentFieldKind @@ -169,5 +169,13 @@ export function coerceIntentFieldValue( throw new Error(`Expected "true" or "false" but received "${raw}"`) } + if (kind === 'string-array') { + if (Array.isArray(raw)) { + return raw + } + + return [String(raw)] + } + return raw } diff --git a/packages/node/src/cli/intentResolvedDefinitions.ts b/packages/node/src/cli/intentResolvedDefinitions.ts index 9e1df947..96c1f8b5 100644 --- a/packages/node/src/cli/intentResolvedDefinitions.ts +++ b/packages/node/src/cli/intentResolvedDefinitions.ts @@ -7,6 +7,7 @@ import type { IntentInputMode, IntentOutputMode, RobotIntentDefinition, + SemanticIntentDefinition, } from './intentCommandSpecs.ts' import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intentCommandSpecs.ts' import type { IntentFieldKind, IntentFieldSpec } from './intentFields.ts' @@ -44,15 +45,25 @@ export interface ResolvedIntentSingleStepExecution { resultStepName: string } +export interface ResolvedIntentDynamicExecution { + fields: GeneratedSchemaField[] + handler: 'image-describe' + kind: 'dynamic-step' + resultStepName: string +} + export interface ResolvedIntentTemplateExecution { kind: 'template' templateId: string } export type ResolvedIntentExecution = + | ResolvedIntentDynamicExecution | ResolvedIntentSingleStepExecution | ResolvedIntentTemplateExecution +export type ResolvedIntentRunnerKind = 'bundled' | 'no-input' | 'standard' | 'watchable' + export interface ResolvedIntentCommandSpec { className: string commandLabel: string @@ -65,6 +76,7 @@ export interface ResolvedIntentCommandSpec { outputDescription: string outputMode?: IntentOutputMode paths: string[] + runnerKind: ResolvedIntentRunnerKind schemaSpec?: ResolvedIntentSchemaSpec } @@ -461,10 +473,85 @@ function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedInte outputDescription: analysis.outputDescription, outputMode: analysis.outputMode, paths: analysis.paths, + runnerKind: + analysis.input.kind === 'none' + ? 'no-input' + : analysis.input.defaultSingleAssembly + ? 'bundled' + : 'standard', schemaSpec: analysis.schemaSpec, } } +function resolveImageDescribeIntentSpec( + definition: SemanticIntentDefinition, +): ResolvedIntentCommandSpec { + const paths = getIntentPaths(definition) + + return { + className: `${toPascalCase(paths)}Command`, + commandLabel: paths.join(' '), + description: 'Describe images as labels or publishable text fields', + details: + 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', + examples: [ + [ + 'Describe an image as labels', + 'transloadit image describe --input hero.jpg --out labels.json', + ], + [ + 'Generate WordPress-ready fields', + 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', + ], + [ + 'Request a custom field set', + 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', + ], + ], + execution: { + kind: 'dynamic-step', + handler: 'image-describe', + resultStepName: 'describe', + fields: [ + { + name: 'fields', + kind: 'string-array', + propertyName: 'fields', + optionFlags: '--fields', + description: + 'Describe output fields to generate, for example labels or altText,title,caption,description', + required: false, + }, + { + name: 'forProfile', + kind: 'string', + propertyName: 'forProfile', + optionFlags: '--for', + description: 'Use a named output profile, currently: wordpress', + required: false, + }, + { + name: 'model', + kind: 'string', + propertyName: 'model', + optionFlags: '--model', + description: + 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', + required: false, + }, + ], + }, + fieldSpecs: [], + input: inferInputSpecFromAnalysis({ + inputMode: 'local-files', + inputPolicy: { kind: 'required' }, + }), + outputDescription: 'Write the JSON result to this path or directory', + paths, + runnerKind: 'watchable', + } +} + function resolveTemplateIntentSpec( definition: IntentDefinition & { kind: 'template' }, ): ResolvedIntentCommandSpec { @@ -494,6 +581,7 @@ function resolveTemplateIntentSpec( : 'Write the result to this path or directory', outputMode, paths, + runnerKind: 'standard', } } @@ -502,6 +590,10 @@ export function resolveIntentCommandSpec(definition: IntentDefinition): Resolved return resolveRobotIntentSpec(definition) } + if (definition.kind === 'semantic') { + return resolveImageDescribeIntentSpec(definition) + } + return resolveTemplateIntentSpec(definition) } diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 0aeb7d9a..b09bff4d 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -37,12 +37,20 @@ export interface IntentSingleStepExecutionDefinition { schema: z.AnyZodObject } +export interface IntentDynamicStepExecutionDefinition { + fields: readonly IntentOptionDefinition[] + handler: 'image-describe' + kind: 'dynamic-step' + resultStepName: string +} + export interface IntentTemplateExecutionDefinition { kind: 'template' templateId: string } export type IntentFileExecutionDefinition = + | IntentDynamicStepExecutionDefinition | IntentSingleStepExecutionDefinition | IntentTemplateExecutionDefinition @@ -67,6 +75,19 @@ export interface IntentOptionDefinition extends IntentFieldSpec { required?: boolean } +const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const + +type ImageDescribeField = (typeof imageDescribeFields)[number] + +const wordpressDescribeFields = [ + 'altText', + 'title', + 'caption', + 'description', +] as const satisfies readonly ImageDescribeField[] + +const defaultDescribeModel = 'anthropic/claude-sonnet-4-5' + function isHttpUrl(value: string): boolean { try { const url = new URL(value) @@ -241,6 +262,217 @@ function createSingleStep( }) } +function parseDescribeFields(value: string[] | undefined): ImageDescribeField[] { + const rawFields = (value ?? []) + .flatMap((part) => part.split(',')) + .map((part) => part.trim()) + .filter(Boolean) + + if (rawFields.length === 0) { + return [] + } + + const fields: ImageDescribeField[] = [] + const seen = new Set() + + for (const rawField of rawFields) { + if (!imageDescribeFields.includes(rawField as ImageDescribeField)) { + throw new Error( + `Unsupported --fields value "${rawField}". Supported values: ${imageDescribeFields.join(', ')}`, + ) + } + + const field = rawField as ImageDescribeField + if (seen.has(field)) { + continue + } + + seen.add(field) + fields.push(field) + } + + return fields +} + +function resolveDescribeProfile(profile: string | undefined): 'wordpress' | null { + if (profile == null) { + return null + } + + if (profile === 'wordpress') { + return 'wordpress' + } + + throw new Error(`Unsupported --for value "${profile}". Supported values: wordpress`) +} + +function resolveRequestedDescribeFields({ + explicitFields, + profile, +}: { + explicitFields: ImageDescribeField[] + profile: 'wordpress' | null +}): ImageDescribeField[] { + if ( + explicitFields.length > 0 && + !(explicitFields.length === 1 && explicitFields[0] === 'labels') + ) { + return explicitFields + } + + if (profile === 'wordpress') { + return [...wordpressDescribeFields] + } + + return explicitFields.length === 0 ? ['labels'] : explicitFields +} + +function validateDescribeFields({ + fields, + model, + profile, +}: { + fields: ImageDescribeField[] + model: string + profile: 'wordpress' | null +}): void { + const includesLabels = fields.includes('labels') + + if (includesLabels && fields.length > 1) { + throw new Error( + 'The labels field cannot be combined with altText, title, caption, or description', + ) + } + + if (includesLabels && profile != null) { + throw new Error('--for cannot be combined with --fields labels') + } + + if (includesLabels && model !== defaultDescribeModel) { + throw new Error( + '--model is only supported when generating altText, title, caption, or description', + ) + } +} + +function resolveImageDescribeRequest(rawValues: Record): { + fields: ImageDescribeField[] + profile: 'wordpress' | null +} { + const explicitFields = parseDescribeFields(rawValues.fields as string[] | undefined) + const profile = resolveDescribeProfile(rawValues.forProfile as string | undefined) + const fields = resolveRequestedDescribeFields({ explicitFields, profile }) + validateDescribeFields({ + fields, + model: String(rawValues.model ?? defaultDescribeModel), + profile, + }) + + return { fields, profile } +} + +function buildDescribeAiChatSchema(fields: readonly ImageDescribeField[]): Record { + const properties = Object.fromEntries( + fields.map((field) => { + const description = + field === 'altText' + ? 'A concise accessibility-focused alt text that objectively describes the image' + : field === 'title' + ? 'A concise publishable title for the image' + : field === 'caption' + ? 'A short caption suitable for displaying below the image' + : 'A richer description of the image suitable for CMS usage' + + return [ + field, + { + type: 'string', + description, + }, + ] + }), + ) + + return { + type: 'object', + additionalProperties: false, + required: [...fields], + properties, + } +} + +function buildDescribeAiChatMessages({ + fields, + profile, +}: { + fields: readonly ImageDescribeField[] + profile: 'wordpress' | null +}): { + messages: string + systemMessage: string +} { + const requestedFields = fields.join(', ') + const profileHint = + profile === 'wordpress' + ? 'The output is for the WordPress media library.' + : 'The output is for a publishing workflow.' + + return { + systemMessage: [ + 'You generate accurate image copy for publishing workflows.', + profileHint, + 'Return only the schema fields requested.', + 'Be concrete, concise, and faithful to what is visibly present in the image.', + 'Do not invent facts, brands, locations, or identities that are not clearly visible.', + 'Avoid keyword stuffing, hype, and mentions of SEO or accessibility in the output itself.', + 'For altText, write one objective sentence focused on what matters to someone who cannot see the image.', + 'For title, keep it short and natural.', + 'For caption, write one short sentence suitable for publication.', + 'For description, write one or two sentences with slightly more context than the caption.', + ].join(' '), + messages: `Analyze the attached image and fill these fields: ${requestedFields}.`, + } +} + +function createDynamicIntentStep( + execution: IntentDynamicStepExecutionDefinition, + rawValues: Record, +): Record { + if (execution.handler !== 'image-describe') { + throw new Error(`Unsupported dynamic intent handler "${execution.handler}"`) + } + + const { fields, profile } = resolveImageDescribeRequest(rawValues) + if (fields.length === 1 && fields[0] === 'labels') { + return { + robot: '/image/describe', + use: ':original', + result: true, + provider: 'aws', + format: 'json', + granularity: 'list', + explicit_descriptions: false, + } + } + + const { messages, systemMessage } = buildDescribeAiChatMessages({ fields, profile }) + + return { + robot: '/ai/chat', + use: ':original', + result: true, + model: String(rawValues.model ?? defaultDescribeModel), + format: 'json', + return_messages: 'last', + test_credentials: true, + schema: JSON.stringify(buildDescribeAiChatSchema(fields)), + messages, + system_message: systemMessage, + // @TODO Move these inline /ai/chat instructions into a builtin template in api2 and + // switch this command to call that builtin instead of shipping prompt logic in the CLI. + } +} + function requiresLocalInput( inputPolicy: IntentInputPolicy, rawValues: Record, @@ -276,12 +508,15 @@ async function executeIntentCommand({ } : { stepsData: { - [definition.execution.resultStepName]: createSingleStep( - definition.execution, - inputPolicy, - rawValues, - createOptions.inputs.length > 0, - ), + [definition.execution.resultStepName]: + definition.execution.kind === 'single-step' + ? createSingleStep( + definition.execution, + inputPolicy, + rawValues, + createOptions.inputs.length > 0, + ) + : createDynamicIntentStep(definition.execution, rawValues), } as AssembliesCreateOptions['stepsData'], } @@ -349,6 +584,13 @@ export function createIntentOption(fieldDefinition: IntentOptionDefinition): unk }) } + if (kind === 'string-array') { + return Option.Array(optionFlags, { + description, + required, + }) + } + return Option.String(optionFlags, { description, required, @@ -358,7 +600,7 @@ export function createIntentOption(fieldDefinition: IntentOptionDefinition): unk export function getIntentOptionDefinitions( definition: IntentFileCommandDefinition | IntentNoInputCommandDefinition, ): readonly IntentOptionDefinition[] { - if (definition.execution.kind !== 'single-step') { + if (definition.execution.kind !== 'single-step' && definition.execution.kind !== 'dynamic-step') { return [] } @@ -468,7 +710,17 @@ export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentComm } protected validateBeforePreparingInputs(rawValues: Record): number | undefined { - return this.validateInputPresence(rawValues) + const validationError = this.validateInputPresence(rawValues) + if (validationError != null) { + return validationError + } + + const execution = this.getIntentDefinition().execution + if (execution.kind === 'dynamic-step') { + createDynamicIntentStep(execution, rawValues) + } + + return undefined } protected validatePreparedInputs(_preparedInputs: PreparedIntentInputs): number | undefined { @@ -528,7 +780,7 @@ export abstract class GeneratedWatchableFileIntentCommand extends GeneratedFileI protected override validateBeforePreparingInputs( rawValues: Record, ): number | undefined { - const validationError = this.validateInputPresence(rawValues) + const validationError = super.validateBeforePreparingInputs(rawValues) if (validationError != null) { return validationError } diff --git a/packages/node/src/cli/intentSmokeCases.ts b/packages/node/src/cli/intentSmokeCases.ts index 4687bc27..a3097d55 100644 --- a/packages/node/src/cli/intentSmokeCases.ts +++ b/packages/node/src/cli/intentSmokeCases.ts @@ -64,6 +64,11 @@ const intentSmokeOverrides: Record Date: Thu, 2 Apr 2026 14:26:02 +0200 Subject: [PATCH 31/44] refactor(node): build intent commands at runtime --- packages/node/package.json | 5 +- .../node/scripts/generate-intent-commands.ts | 241 -- .../src/cli/commands/generated-intents.ts | 2266 ----------------- packages/node/src/cli/commands/index.ts | 5 +- packages/node/src/cli/intentCommands.ts | 116 + packages/node/test/unit/cli/intents.test.ts | 2 +- scripts/prepare-transloadit.ts | 3 - 7 files changed, 120 insertions(+), 2518 deletions(-) delete mode 100644 packages/node/scripts/generate-intent-commands.ts delete mode 100644 packages/node/src/cli/commands/generated-intents.ts create mode 100644 packages/node/src/cli/intentCommands.ts diff --git a/packages/node/package.json b/packages/node/package.json index 846b6045..6d90e59c 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -82,8 +82,7 @@ "src": "./src" }, "scripts": { - "sync:intents": "node scripts/generate-intent-commands.ts", - "check": "yarn sync:intents && yarn lint:ts && yarn fix && yarn test:unit", + "check": "yarn lint:ts && yarn fix && yarn test:unit", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", @@ -92,7 +91,7 @@ "fix": "yarn fix:js", "lint:deps": "knip --dependencies --no-progress", "fix:deps": "knip --dependencies --no-progress --fix", - "prepack": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && rm -f tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo && yarn sync:intents && yarn --cwd ../.. tsc:node", + "prepack": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && rm -f tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo && yarn --cwd ../.. tsc:node", "test:unit": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage ./test/unit", "test:e2e": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run ./test/e2e", "test": "yarn --cwd ../.. tsc:utils && ../../node_modules/.bin/vitest run --coverage" diff --git a/packages/node/scripts/generate-intent-commands.ts b/packages/node/scripts/generate-intent-commands.ts deleted file mode 100644 index 356f0d2d..00000000 --- a/packages/node/scripts/generate-intent-commands.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { execa } from 'execa' - -import type { - GeneratedSchemaField, - ResolvedIntentCommandSpec, -} from '../src/cli/intentResolvedDefinitions.ts' -import { resolveIntentCommandSpecs } from '../src/cli/intentResolvedDefinitions.ts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packageRoot = path.resolve(__dirname, '..') -const outputPath = path.resolve(__dirname, '../src/cli/commands/generated-intents.ts') - -function formatDescription(description: string | undefined): string { - return JSON.stringify((description ?? '').trim()) -} - -function formatUsageExamples(examples: Array<[string, string]>): string { - return examples - .map(([label, example]) => ` [${JSON.stringify(label)}, ${JSON.stringify(example)}],`) - .join('\n') -} - -function formatFieldDefinitionsName(spec: ResolvedIntentCommandSpec): string { - return `${spec.className[0]?.toLowerCase() ?? ''}${spec.className.slice(1)}Fields` -} - -function getOptionFields(spec: ResolvedIntentCommandSpec): readonly GeneratedSchemaField[] { - if (spec.execution.kind === 'dynamic-step') { - return spec.execution.fields - } - - return spec.fieldSpecs -} - -function formatSchemaFields(spec: ResolvedIntentCommandSpec): string { - return getOptionFields(spec) - .map((fieldSpec) => { - return ` ${fieldSpec.propertyName} = createIntentOption(${formatFieldDefinitionsName(spec)}.${fieldSpec.propertyName})` - }) - .join('\n\n') -} - -function formatFieldDefinitions(spec: ResolvedIntentCommandSpec): string { - const fieldSpecs = getOptionFields(spec) - if (fieldSpecs.length === 0) { - return '' - } - - return `const ${formatFieldDefinitionsName(spec)} = { -${fieldSpecs - .map((fieldSpec) => { - const requiredLine = fieldSpec.required ? '\n required: true,' : '' - return ` ${fieldSpec.propertyName}: { - name: ${JSON.stringify(fieldSpec.name)}, - kind: ${JSON.stringify(fieldSpec.kind)}, - propertyName: ${JSON.stringify(fieldSpec.propertyName)}, - optionFlags: ${JSON.stringify(fieldSpec.optionFlags)}, - description: ${formatDescription(fieldSpec.description)},${requiredLine} - },` - }) - .join('\n')} -} as const` -} - -function generateImports(specs: ResolvedIntentCommandSpec[]): string { - const imports = new Map() - - for (const spec of specs) { - if (spec.schemaSpec == null) continue - imports.set(spec.schemaSpec.importName, spec.schemaSpec.importPath) - } - - return [...imports.entries()] - .sort(([nameA], [nameB]) => nameA.localeCompare(nameB)) - .map(([importName, importPath]) => `import { ${importName} } from '${importPath}'`) - .join('\n') -} - -function getCommandDefinitionName(spec: ResolvedIntentCommandSpec): string { - return `${spec.className[0]?.toLowerCase() ?? ''}${spec.className.slice(1)}Definition` -} - -function getBaseClassName(spec: ResolvedIntentCommandSpec): string { - if (spec.runnerKind === 'no-input') { - return 'GeneratedNoInputIntentCommand' - } - - if (spec.runnerKind === 'bundled') { - return 'GeneratedBundledFileIntentCommand' - } - - if (spec.runnerKind === 'watchable') { - return 'GeneratedWatchableFileIntentCommand' - } - - return 'GeneratedStandardFileIntentCommand' -} - -function formatIntentDefinition(spec: ResolvedIntentCommandSpec): string { - if (spec.execution.kind === 'single-step') { - const commandLabelLine = - spec.input.kind === 'local-files' - ? `\n commandLabel: ${JSON.stringify(spec.commandLabel)},` - : '' - const inputPolicyLine = - spec.input.kind === 'local-files' - ? `\n inputPolicy: ${JSON.stringify(spec.input.inputPolicy, null, 4).replaceAll('\n', '\n ')},` - : '' - const outputMode = - spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` - const outputLines = `\n outputDescription: ${JSON.stringify(spec.outputDescription)},` - const fieldsLine = - spec.fieldSpecs.length === 0 ? '[]' : `Object.values(${formatFieldDefinitionsName(spec)})` - - return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${inputPolicyLine}${outputMode}${outputLines} - execution: { - kind: 'single-step', - schema: ${spec.schemaSpec?.importName}, - fields: ${fieldsLine}, - fixedValues: ${JSON.stringify(spec.execution.fixedValues, null, 4).replaceAll('\n', '\n ')}, - resultStepName: ${JSON.stringify(spec.execution.resultStepName)}, - }, -} as const` - } - - if (spec.execution.kind === 'dynamic-step') { - const commandLabelLine = - spec.input.kind === 'local-files' - ? `\n commandLabel: ${JSON.stringify(spec.commandLabel)},` - : '' - const inputPolicyLine = - spec.input.kind === 'local-files' - ? `\n inputPolicy: ${JSON.stringify(spec.input.inputPolicy, null, 4).replaceAll('\n', '\n ')},` - : '' - const outputMode = - spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` - - return `const ${getCommandDefinitionName(spec)} = {${commandLabelLine}${inputPolicyLine}${outputMode} - outputDescription: ${JSON.stringify(spec.outputDescription)}, - execution: { - kind: 'dynamic-step', - handler: ${JSON.stringify(spec.execution.handler)}, - fields: Object.values(${formatFieldDefinitionsName(spec)}), - resultStepName: ${JSON.stringify(spec.execution.resultStepName)}, - }, -} as const` - } - - const outputMode = - spec.outputMode == null ? '' : `\n outputMode: ${JSON.stringify(spec.outputMode)},` - return `const ${getCommandDefinitionName(spec)} = { - commandLabel: ${JSON.stringify(spec.commandLabel)}, - inputPolicy: { "kind": "required" },${outputMode} - outputDescription: ${JSON.stringify(spec.outputDescription)}, - execution: { - kind: 'template', - templateId: ${JSON.stringify(spec.execution.templateId)}, - }, -} as const` -} - -function generateClass(spec: ResolvedIntentCommandSpec): string { - const schemaFields = formatSchemaFields(spec) - const baseClassName = getBaseClassName(spec) - - return ` -class ${spec.className} extends ${baseClassName} { - static override paths = ${JSON.stringify([spec.paths])} - - static override intentDefinition = ${getCommandDefinitionName(spec)} - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: ${JSON.stringify(spec.description)}, - details: ${JSON.stringify(spec.details)}, - examples: [ -${formatUsageExamples(spec.examples)} - ], - }) - -${schemaFields} -} -` -} - -function generateFile(specs: ResolvedIntentCommandSpec[]): string { - const fieldDefinitions = specs - .map((spec) => formatFieldDefinitions(spec)) - .filter((definition) => definition.length > 0) - const commandDefinitions = specs.map(formatIntentDefinition) - const commandClasses = specs.map(generateClass) - const commandNames = specs.map((spec) => spec.className) - - return `// DO NOT EDIT BY HAND. -// Generated by \`packages/node/scripts/generate-intent-commands.ts\`. - -import { Command } from 'clipanion' - -${generateImports(specs)} -import { - createIntentOption, - GeneratedBundledFileIntentCommand, - GeneratedNoInputIntentCommand, - GeneratedStandardFileIntentCommand, - GeneratedWatchableFileIntentCommand, -} from '../intentRuntime.ts' -${fieldDefinitions.join('\n\n')} -${commandDefinitions.join('\n\n')} -${commandClasses.join('\n')} -export const intentCommands = [ -${commandNames.map((name) => ` ${name},`).join('\n')} -] as const -` -} - -async function main(): Promise { - const resolvedSpecs = resolveIntentCommandSpecs() - - await mkdir(path.dirname(outputPath), { recursive: true }) - await writeFile(outputPath, generateFile(resolvedSpecs)) - await execa( - 'yarn', - ['exec', 'biome', 'check', '--write', path.relative(packageRoot, outputPath)], - { - cwd: packageRoot, - }, - ) -} - -main().catch((error) => { - if (!(error instanceof Error)) { - throw new Error(`Was thrown a non-error: ${error}`) - } - - console.error(error) - process.exit(1) -}) diff --git a/packages/node/src/cli/commands/generated-intents.ts b/packages/node/src/cli/commands/generated-intents.ts deleted file mode 100644 index 70a61a34..00000000 --- a/packages/node/src/cli/commands/generated-intents.ts +++ /dev/null @@ -1,2266 +0,0 @@ -// DO NOT EDIT BY HAND. -// Generated by `packages/node/scripts/generate-intent-commands.ts`. - -import { Command } from 'clipanion' - -import { robotAudioWaveformInstructionsSchema } from '../../alphalib/types/robots/audio-waveform.ts' -import { robotDocumentAutorotateInstructionsSchema } from '../../alphalib/types/robots/document-autorotate.ts' -import { robotDocumentConvertInstructionsSchema } from '../../alphalib/types/robots/document-convert.ts' -import { robotDocumentOptimizeInstructionsSchema } from '../../alphalib/types/robots/document-optimize.ts' -import { robotDocumentThumbsInstructionsSchema } from '../../alphalib/types/robots/document-thumbs.ts' -import { robotFileCompressInstructionsSchema } from '../../alphalib/types/robots/file-compress.ts' -import { robotFileDecompressInstructionsSchema } from '../../alphalib/types/robots/file-decompress.ts' -import { robotFilePreviewInstructionsSchema } from '../../alphalib/types/robots/file-preview.ts' -import { robotImageBgremoveInstructionsSchema } from '../../alphalib/types/robots/image-bgremove.ts' -import { robotImageGenerateInstructionsSchema } from '../../alphalib/types/robots/image-generate.ts' -import { robotImageOptimizeInstructionsSchema } from '../../alphalib/types/robots/image-optimize.ts' -import { robotImageResizeInstructionsSchema } from '../../alphalib/types/robots/image-resize.ts' -import { robotTextSpeakInstructionsSchema } from '../../alphalib/types/robots/text-speak.ts' -import { robotVideoThumbsInstructionsSchema } from '../../alphalib/types/robots/video-thumbs.ts' -import { - createIntentOption, - GeneratedBundledFileIntentCommand, - GeneratedNoInputIntentCommand, - GeneratedStandardFileIntentCommand, - GeneratedWatchableFileIntentCommand, -} from '../intentRuntime.ts' - -const imageGenerateCommandFields = { - model: { - name: 'model', - kind: 'string', - propertyName: 'model', - optionFlags: '--model', - description: 'The AI model to use for image generation. Defaults to google/nano-banana.', - }, - prompt: { - name: 'prompt', - kind: 'string', - propertyName: 'prompt', - optionFlags: '--prompt', - description: 'The prompt describing the desired image content.', - required: true, - }, - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: 'Format of the generated image.', - }, - seed: { - name: 'seed', - kind: 'number', - propertyName: 'seed', - optionFlags: '--seed', - description: 'Seed for the random number generator.', - }, - aspectRatio: { - name: 'aspect_ratio', - kind: 'string', - propertyName: 'aspectRatio', - optionFlags: '--aspect-ratio', - description: 'Aspect ratio of the generated image.', - }, - height: { - name: 'height', - kind: 'number', - propertyName: 'height', - optionFlags: '--height', - description: 'Height of the generated image.', - }, - width: { - name: 'width', - kind: 'number', - propertyName: 'width', - optionFlags: '--width', - description: 'Width of the generated image.', - }, - style: { - name: 'style', - kind: 'string', - propertyName: 'style', - optionFlags: '--style', - description: 'Style of the generated image.', - }, - numOutputs: { - name: 'num_outputs', - kind: 'number', - propertyName: 'numOutputs', - optionFlags: '--num-outputs', - description: 'Number of image variants to generate.', - }, -} as const - -const previewGenerateCommandFields = { - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: - 'The output format for the generated thumbnail image. If a short video clip is generated using the `clip` strategy, its format is defined by `clip_format`.', - }, - width: { - name: 'width', - kind: 'number', - propertyName: 'width', - optionFlags: '--width', - description: 'Width of the thumbnail, in pixels.', - }, - height: { - name: 'height', - kind: 'number', - propertyName: 'height', - optionFlags: '--height', - description: 'Height of the thumbnail, in pixels.', - }, - resizeStrategy: { - name: 'resize_strategy', - kind: 'string', - propertyName: 'resizeStrategy', - optionFlags: '--resize-strategy', - description: - 'To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. This happens, for example, when the dimensions of a frame extracted from a video do not match the chosen `width` and `height` parameters.\n\nSee the list of available [resize strategies](/docs/topics/resize-strategies/) for more details.', - }, - background: { - name: 'background', - kind: 'string', - propertyName: 'background', - optionFlags: '--background', - description: - 'The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). The format is `#rrggbb[aa]` (red, green, blue, alpha). Use `#00000000` for a transparent padding.', - }, - strategy: { - name: 'strategy', - kind: 'json', - propertyName: 'strategy', - optionFlags: '--strategy', - description: - 'Definition of the thumbnail generation process per file category. The parameter must be an object whose keys can be one of the file categories: `audio`, `video`, `image`, `document`, `archive`, `webpage`, and `unknown`. The corresponding value is an array of strategies for the specific file category. See the above section for a list of all available strategies.\n\nFor each file, the Robot will attempt to use the first strategy to generate the thumbnail. If this process fails (e.g., because no artwork is available in a video file), the next strategy is attempted. This is repeated until either a thumbnail is generated or the list is exhausted. Selecting the `icon` strategy as the last entry provides a fallback mechanism to ensure that an appropriate strategy is always available.\n\nThe parameter defaults to the following definition:\n\n```json\n{\n "audio": ["artwork", "waveform", "icon"],\n "video": ["artwork", "frame", "icon"],\n "document": ["page", "icon"],\n "image": ["image", "icon"],\n "webpage": ["render", "icon"],\n "archive": ["icon"],\n "unknown": ["icon"]\n}\n```', - }, - artworkOuterColor: { - name: 'artwork_outer_color', - kind: 'string', - propertyName: 'artworkOuterColor', - optionFlags: '--artwork-outer-color', - description: "The color used in the outer parts of the artwork's gradient.", - }, - artworkCenterColor: { - name: 'artwork_center_color', - kind: 'string', - propertyName: 'artworkCenterColor', - optionFlags: '--artwork-center-color', - description: "The color used in the center of the artwork's gradient.", - }, - waveformCenterColor: { - name: 'waveform_center_color', - kind: 'string', - propertyName: 'waveformCenterColor', - optionFlags: '--waveform-center-color', - description: - "The color used in the center of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", - }, - waveformOuterColor: { - name: 'waveform_outer_color', - kind: 'string', - propertyName: 'waveformOuterColor', - optionFlags: '--waveform-outer-color', - description: - "The color used in the outer parts of the waveform's gradient. The format is `#rrggbb[aa]` (red, green, blue, alpha). Only used if the `waveform` strategy for audio files is applied.", - }, - waveformHeight: { - name: 'waveform_height', - kind: 'number', - propertyName: 'waveformHeight', - optionFlags: '--waveform-height', - description: - 'Height of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', - }, - waveformWidth: { - name: 'waveform_width', - kind: 'number', - propertyName: 'waveformWidth', - optionFlags: '--waveform-width', - description: - 'Width of the waveform, in pixels. Only used if the `waveform` strategy for audio files is applied. It can be utilized to ensure that the waveform only takes up a section of the preview thumbnail.', - }, - iconStyle: { - name: 'icon_style', - kind: 'string', - propertyName: 'iconStyle', - optionFlags: '--icon-style', - description: - 'The style of the icon generated if the `icon` strategy is applied. The default style, `with-text`, includes an icon showing the file type and a text box below it, whose content can be controlled by the `icon_text_content` parameter and defaults to the file extension (e.g. MP4, JPEG). The `square` style only includes a square variant of the icon showing the file type. Below are exemplary previews generated for a text file utilizing the different styles:\n\n

`with-text` style:
\n![Image with text style]({{site.asset_cdn}}/assets/images/file-preview/icon-with-text.png)\n

`square` style:
\n![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png)', - }, - iconTextColor: { - name: 'icon_text_color', - kind: 'string', - propertyName: 'iconTextColor', - optionFlags: '--icon-text-color', - description: - 'The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied.', - }, - iconTextFont: { - name: 'icon_text_font', - kind: 'string', - propertyName: 'iconTextFont', - optionFlags: '--icon-text-font', - description: - 'The font family of the text used in the icon. Only used if the `icon` strategy is applied. [Here](/docs/supported-formats/fonts/) is a list of all supported fonts.', - }, - iconTextContent: { - name: 'icon_text_content', - kind: 'string', - propertyName: 'iconTextContent', - optionFlags: '--icon-text-content', - description: - 'The content of the text box in generated icons. Only used if the `icon_style` parameter is set to `with-text`. The default value, `extension`, adds the file extension (e.g. MP4, JPEG) to the icon. The value `none` can be used to render an empty text box, which is useful if no text should not be included in the raster image, but some place should be reserved in the image for later overlaying custom text over the image using HTML etc.', - }, - optimize: { - name: 'optimize', - kind: 'boolean', - propertyName: 'optimize', - optionFlags: '--optimize', - description: - "Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. If enabled, the images will be optimized using [🤖/image/optimize](/docs/robots/image-optimize/).", - }, - optimizePriority: { - name: 'optimize_priority', - kind: 'string', - propertyName: 'optimizePriority', - optionFlags: '--optimize-priority', - description: - 'Specifies whether conversion speed or compression ratio is prioritized when optimizing images. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-priority) for more details.', - }, - optimizeProgressive: { - name: 'optimize_progressive', - kind: 'boolean', - propertyName: 'optimizeProgressive', - optionFlags: '--optimize-progressive', - description: - 'Specifies whether images should be interlaced, which makes the result image load progressively in browsers. Only used if `optimize` is enabled. Please see the [🤖/image/optimize documentation](/docs/robots/image-optimize/#param-progressive) for more details.', - }, - clipFormat: { - name: 'clip_format', - kind: 'string', - propertyName: 'clipFormat', - optionFlags: '--clip-format', - description: - 'The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied.\n\nPlease consult the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types) for detailed information about the image formats and their characteristics. GIF enjoys the broadest support in software, but only supports a limit color palette. APNG supports a variety of color depths, but its lossless compression produces large images for videos. AVIF is a modern image format that offers great compression, but proper support for animations is still lacking in some browsers. WebP on the other hand, enjoys broad support while offering a great balance between small file sizes and good visual quality, making it the default clip format.', - }, - clipOffset: { - name: 'clip_offset', - kind: 'number', - propertyName: 'clipOffset', - optionFlags: '--clip-offset', - description: - 'The start position in seconds of where the clip is cut. Only used if the `clip` strategy for video files is applied. Be aware that for larger video only the first few MBs of the file may be imported to improve speed. Larger offsets may seek to a position outside of the imported part and thus fail to generate a clip.', - }, - clipDuration: { - name: 'clip_duration', - kind: 'number', - propertyName: 'clipDuration', - optionFlags: '--clip-duration', - description: - 'The duration in seconds of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a longer clip duration also results in a larger file size, which might be undesirable for previews.', - }, - clipFramerate: { - name: 'clip_framerate', - kind: 'number', - propertyName: 'clipFramerate', - optionFlags: '--clip-framerate', - description: - 'The framerate of the generated video clip. Only used if the `clip` strategy for video files is applied. Be aware that a higher framerate appears smoother but also results in a larger file size, which might be undesirable for previews.', - }, - clipLoop: { - name: 'clip_loop', - kind: 'boolean', - propertyName: 'clipLoop', - optionFlags: '--clip-loop', - description: - 'Specifies whether the generated animated image should loop forever (`true`) or stop after playing the animation once (`false`). Only used if the `clip` strategy for video files is applied.', - }, -} as const - -const imageRemoveBackgroundCommandFields = { - select: { - name: 'select', - kind: 'string', - propertyName: 'select', - optionFlags: '--select', - description: 'Region to select and keep in the image. The other region is removed.', - }, - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: 'Format of the generated image.', - }, - provider: { - name: 'provider', - kind: 'string', - propertyName: 'provider', - optionFlags: '--provider', - description: 'Provider to use for removing the background.', - }, - model: { - name: 'model', - kind: 'string', - propertyName: 'model', - optionFlags: '--model', - description: - 'Provider-specific model to use for removing the background. Mostly intended for testing and evaluation.', - }, -} as const - -const imageOptimizeCommandFields = { - priority: { - name: 'priority', - kind: 'string', - propertyName: 'priority', - optionFlags: '--priority', - description: - 'Provides different algorithms for better or worse compression for your images, but that run slower or faster. The value `"conversion-speed"` will result in an average compression ratio of 18%. `"compression-ratio"` will result in an average compression ratio of 31%.', - }, - progressive: { - name: 'progressive', - kind: 'boolean', - propertyName: 'progressive', - optionFlags: '--progressive', - description: - 'Interlaces the image if set to `true`, which makes the result image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the image which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a loss of about 10% of the file size reduction.', - }, - preserveMetaData: { - name: 'preserve_meta_data', - kind: 'boolean', - propertyName: 'preserveMetaData', - optionFlags: '--preserve-meta-data', - description: - "Specifies if the image's metadata should be preserved during the optimization, or not. If it is not preserved, the file size is even further reduced. But be aware that this could strip a photographer's copyright information, which for obvious reasons can be frowned upon.", - }, - fixBreakingImages: { - name: 'fix_breaking_images', - kind: 'boolean', - propertyName: 'fixBreakingImages', - optionFlags: '--fix-breaking-images', - description: - 'If set to `true` this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies. This can sometimes result in a larger file size, though.', - }, -} as const - -const imageResizeCommandFields = { - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: - 'The output format for the modified image.\n\nSome of the most important available formats are `"jpg"`, `"png"`, `"gif"`, and `"tiff"`. For a complete lists of all formats that we can write to please check [our supported image formats list](/docs/supported-formats/image-formats/).\n\nIf `null` (default), then the input image\'s format will be used as the output format.\n\nIf you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead.', - }, - width: { - name: 'width', - kind: 'number', - propertyName: 'width', - optionFlags: '--width', - description: - 'Width of the result in pixels. If not specified, will default to the width of the original.', - }, - height: { - name: 'height', - kind: 'number', - propertyName: 'height', - optionFlags: '--height', - description: - 'Height of the new image, in pixels. If not specified, will default to the height of the input image.', - }, - resizeStrategy: { - name: 'resize_strategy', - kind: 'string', - propertyName: 'resizeStrategy', - optionFlags: '--resize-strategy', - description: 'See the list of available [resize strategies](/docs/topics/resize-strategies/).', - }, - zoom: { - name: 'zoom', - kind: 'boolean', - propertyName: 'zoom', - optionFlags: '--zoom', - description: - 'If this is set to `false`, smaller images will not be stretched to the desired width and height. For details about the impact of zooming for your preferred resize strategy, see the list of available [resize strategies](/docs/topics/resize-strategies/).', - }, - crop: { - name: 'crop', - kind: 'auto', - propertyName: 'crop', - optionFlags: '--crop', - description: - 'Specify an object containing coordinates for the top left and bottom right corners of the rectangle to be cropped from the original image(s). The coordinate system is rooted in the top left corner of the image. Values can be integers for absolute pixel values or strings for percentage based values.\n\nFor example:\n\n```json\n{\n "x1": 80,\n "y1": 100,\n "x2": "60%",\n "y2": "80%"\n}\n```\n\nThis will crop the area from `(80, 100)` to `(600, 800)` from a 1000×1000 pixels image, which is a square whose width is 520px and height is 700px. If `crop` is set, the width and height parameters are ignored, and the `resize_strategy` is set to `crop` automatically.\n\nYou can also use a JSON string of such an object with coordinates in similar fashion:\n\n```json\n"{\\"x1\\": , \\"y1\\": , \\"x2\\": , \\"y2\\": }"\n```\n\nTo crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/).', - }, - gravity: { - name: 'gravity', - kind: 'string', - propertyName: 'gravity', - optionFlags: '--gravity', - description: - 'The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined.', - }, - strip: { - name: 'strip', - kind: 'boolean', - propertyName: 'strip', - optionFlags: '--strip', - description: - 'Strips all metadata from the image. This is useful to keep thumbnails as small as possible.', - }, - alpha: { - name: 'alpha', - kind: 'string', - propertyName: 'alpha', - optionFlags: '--alpha', - description: 'Gives control of the alpha/matte channel of an image.', - }, - preclipAlpha: { - name: 'preclip_alpha', - kind: 'string', - propertyName: 'preclipAlpha', - optionFlags: '--preclip-alpha', - description: - 'Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`.', - }, - flatten: { - name: 'flatten', - kind: 'boolean', - propertyName: 'flatten', - optionFlags: '--flatten', - description: - 'Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#layers).\n\nTo preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter.', - }, - correctGamma: { - name: 'correct_gamma', - kind: 'boolean', - propertyName: 'correctGamma', - optionFlags: '--correct-gamma', - description: - 'Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html).', - }, - quality: { - name: 'quality', - kind: 'number', - propertyName: 'quality', - optionFlags: '--quality', - description: - 'Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', - }, - adaptiveFiltering: { - name: 'adaptive_filtering', - kind: 'boolean', - propertyName: 'adaptiveFiltering', - optionFlags: '--adaptive-filtering', - description: - 'Controls the image compression for PNG images. Setting to `true` results in smaller file size, while increasing processing time. It is encouraged to keep this option disabled.', - }, - background: { - name: 'background', - kind: 'string', - propertyName: 'background', - optionFlags: '--background', - description: - 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (used for the `pad` resize strategy).\n\n**Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`.', - }, - frame: { - name: 'frame', - kind: 'number', - propertyName: 'frame', - optionFlags: '--frame', - description: - 'Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. Specify `1` to use the first frame, `2` to use the second, and so on. `null` means all frames.', - }, - colorspace: { - name: 'colorspace', - kind: 'string', - propertyName: 'colorspace', - optionFlags: '--colorspace', - description: - 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace). Please note that if you were using `"RGB"`, we recommend using `"sRGB"` instead as of 2014-02-04. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might have to use this parameter in combination with `type: "TrueColor"`.', - }, - type: { - name: 'type', - kind: 'string', - propertyName: 'type', - optionFlags: '--type', - description: - 'Sets the image color type. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#type). If you\'re using `colorspace`, ImageMagick might try to find the most efficient based on the color of an image, and default to e.g. `"Gray"`. To force colors, you could e.g. set this parameter to `"TrueColor"`', - }, - sepia: { - name: 'sepia', - kind: 'number', - propertyName: 'sepia', - optionFlags: '--sepia', - description: 'Applies a sepia tone effect in percent.', - }, - rotation: { - name: 'rotation', - kind: 'auto', - propertyName: 'rotation', - optionFlags: '--rotation', - description: - 'Determines whether the image should be rotated. Use any number to specify the rotation angle in degrees (e.g., `90`, `180`, `270`, `360`, or precise values like `2.9`). Use the value `true` or `"auto"` to auto-rotate images that are rotated incorrectly or depend on EXIF rotation settings. Otherwise, use `false` to disable auto-fixing altogether.', - }, - compress: { - name: 'compress', - kind: 'string', - propertyName: 'compress', - optionFlags: '--compress', - description: - 'Specifies pixel compression for when the image is written. Compression is disabled by default.\n\nPlease also take a look at [🤖/image/optimize](/docs/robots/image-optimize/).', - }, - blur: { - name: 'blur', - kind: 'string', - propertyName: 'blur', - optionFlags: '--blur', - description: - 'Specifies gaussian blur, using a value with the form `{radius}x{sigma}`. The radius value specifies the size of area the operator should look at when spreading pixels, and should typically be either `"0"` or at least two times the sigma value. The sigma value is an approximation of how many pixels the image is "spread"; think of it as the size of the brush used to blur the image. This number is a floating point value, enabling small values like `"0.5"` to be used.', - }, - blurRegions: { - name: 'blur_regions', - kind: 'json', - propertyName: 'blurRegions', - optionFlags: '--blur-regions', - description: - 'Specifies an array of ellipse objects that should be blurred on the image. Each object has the following keys: `x`, `y`, `width`, `height`. If `blur_regions` has a value, then the `blur` parameter is used as the strength of the blur for each region.', - }, - brightness: { - name: 'brightness', - kind: 'number', - propertyName: 'brightness', - optionFlags: '--brightness', - description: - 'Increases or decreases the brightness of the image by using a multiplier. For example `1.5` would increase the brightness by 50%, and `0.75` would decrease the brightness by 25%.', - }, - saturation: { - name: 'saturation', - kind: 'number', - propertyName: 'saturation', - optionFlags: '--saturation', - description: - 'Increases or decreases the saturation of the image by using a multiplier. For example `1.5` would increase the saturation by 50%, and `0.75` would decrease the saturation by 25%.', - }, - hue: { - name: 'hue', - kind: 'number', - propertyName: 'hue', - optionFlags: '--hue', - description: - 'Changes the hue by rotating the color of the image. The value `100` would produce no change whereas `0` and `200` will negate the colors in the image.', - }, - contrast: { - name: 'contrast', - kind: 'number', - propertyName: 'contrast', - optionFlags: '--contrast', - description: - 'Adjusts the contrast of the image. A value of `1` produces no change. Values below `1` decrease contrast (with `0` being minimum contrast), and values above `1` increase contrast (with `2` being maximum contrast). This works like the `brightness` parameter.', - }, - watermarkUrl: { - name: 'watermark_url', - kind: 'string', - propertyName: 'watermarkUrl', - optionFlags: '--watermark-url', - description: - 'A URL indicating a PNG image to be overlaid above this image. Please note that you can also [supply the watermark via another Assembly Step](/docs/topics/use-parameter/#supplying-the-watermark-via-an-assembly-step). With watermarking you can add an image onto another image. This is usually used for logos.', - }, - watermarkPosition: { - name: 'watermark_position', - kind: 'auto', - propertyName: 'watermarkPosition', - optionFlags: '--watermark-position', - description: - 'The position at which the watermark is placed. The available options are `"center"`, `"top"`, `"bottom"`, `"left"`, and `"right"`. You can also combine options, such as `"bottom-right"`.\n\nAn array of possible values can also be specified, in which case one value will be selected at random, such as `[ "center", "left", "bottom-left", "bottom-right" ]`.\n\nThis setting puts the watermark in the specified corner. To use a specific pixel offset for the watermark, you will need to add the padding to the image itself.', - }, - watermarkXOffset: { - name: 'watermark_x_offset', - kind: 'number', - propertyName: 'watermarkXOffset', - optionFlags: '--watermark-x-offset', - description: - "The x-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", - }, - watermarkYOffset: { - name: 'watermark_y_offset', - kind: 'number', - propertyName: 'watermarkYOffset', - optionFlags: '--watermark-y-offset', - description: - "The y-offset in number of pixels at which the watermark will be placed in relation to the position it has due to `watermark_position`.\n\nValues can be both positive and negative and yield different results depending on the `watermark_position` parameter. Positive values move the watermark closer to the image's center point, whereas negative values move the watermark further away from the image's center point.", - }, - watermarkSize: { - name: 'watermark_size', - kind: 'string', - propertyName: 'watermarkSize', - optionFlags: '--watermark-size', - description: - 'The size of the watermark, as a percentage.\n\nFor example, a value of `"50%"` means that size of the watermark will be 50% of the size of image on which it is placed. The exact sizing depends on `watermark_resize_strategy`, too.', - }, - watermarkResizeStrategy: { - name: 'watermark_resize_strategy', - kind: 'string', - propertyName: 'watermarkResizeStrategy', - optionFlags: '--watermark-resize-strategy', - description: - 'Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`.\n\nTo explain how the resize strategies work, let\'s assume our target image size is 800×800 pixels and our watermark image is 400×300 pixels. Let\'s also assume, the `watermark_size` parameter is set to `"25%"`.\n\nFor the `"fit"` resize strategy, the watermark is scaled so that the longer side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the width is the longer side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 200×150 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 400×300 pixels (so just left at its original size).\n\nFor the `"min_fit"` resize strategy, the watermark is scaled so that the shorter side of the watermark takes up 25% of the corresponding image side. And the other side is scaled according to the aspect ratio of the watermark image. So with our watermark, the height is the shorter side, and 25% of the image size would be 200px. Hence, the watermark would be resized to 267×200 pixels. If the `watermark_size` was set to `"50%"`, it would be resized to 533×400 pixels (so larger than its original size).\n\nFor the `"stretch"` resize strategy, the watermark is stretched (meaning, it is resized without keeping its aspect ratio in mind) so that both sides take up 25% of the corresponding image side. Since our image is 800×800 pixels, for a watermark size of 25% the watermark would be resized to 200×200 pixels. Its height would appear stretched, because keeping the aspect ratio in mind it would be resized to 200×150 pixels instead.\n\nFor the `"area"` resize strategy, the watermark is resized (keeping its aspect ratio in check) so that it covers `"xx%"` of the image\'s surface area. The value from `watermark_size` is used for the percentage area size.', - }, - watermarkOpacity: { - name: 'watermark_opacity', - kind: 'number', - propertyName: 'watermarkOpacity', - optionFlags: '--watermark-opacity', - description: - 'The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque.\n\nFor example, a value of `0.5` means the watermark will be 50% transparent, allowing the underlying image to show through. This is useful for subtle branding or when you want the watermark to be less obtrusive.', - }, - watermarkRepeatX: { - name: 'watermark_repeat_x', - kind: 'boolean', - propertyName: 'watermarkRepeatX', - optionFlags: '--watermark-repeat-x', - description: - 'When set to `true`, the watermark will be repeated horizontally across the entire width of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark.', - }, - watermarkRepeatY: { - name: 'watermark_repeat_y', - kind: 'boolean', - propertyName: 'watermarkRepeatY', - optionFlags: '--watermark-repeat-y', - description: - 'When set to `true`, the watermark will be repeated vertically across the entire height of the image.\n\nThis is useful for creating tiled watermark patterns that cover the full image. Can be combined with `watermark_repeat_x` to tile in both directions.', - }, - text: { - name: 'text', - kind: 'json', - propertyName: 'text', - optionFlags: '--text', - description: - 'Text overlays to be applied to the image. Can be either a single text object or an array of text objects. Each text object contains text rules. The following text parameters are intended to be used as properties for your text overlays. Here is an example:\n\n```json\n"watermarked": {\n "use": "resized",\n "robot": "/image/resize",\n "text": [\n {\n "text": "© 2018 Transloadit.com",\n "size": 12,\n "font": "Ubuntu",\n "color": "#eeeeee",\n "valign": "bottom",\n "align": "right",\n "x_offset": 16,\n "y_offset": -10\n }\n ]\n}\n```', - }, - progressive: { - name: 'progressive', - kind: 'boolean', - propertyName: 'progressive', - optionFlags: '--progressive', - description: - 'Interlaces the image if set to `true`, which makes the image load progressively in browsers. Instead of rendering the image from top to bottom, the browser will first show a low-res blurry version of the images which is then quickly replaced with the actual image as the data arrives. This greatly increases the user experience, but comes at a cost of a file size increase by around 10%.', - }, - transparent: { - name: 'transparent', - kind: 'string', - propertyName: 'transparent', - optionFlags: '--transparent', - description: 'Make this color transparent within the image. Example: `"255,255,255"`.', - }, - trimWhitespace: { - name: 'trim_whitespace', - kind: 'boolean', - propertyName: 'trimWhitespace', - optionFlags: '--trim-whitespace', - description: - 'This determines if additional whitespace around the image should first be trimmed away. If you set this to `true` this parameter removes any edges that are exactly the same color as the corner pixels.', - }, - clip: { - name: 'clip', - kind: 'auto', - propertyName: 'clip', - optionFlags: '--clip', - description: - 'Apply the clipping path to other operations in the resize job, if one is present. If set to `true`, it will automatically take the first clipping path. If set to a String it finds a clipping path by that name.', - }, - negate: { - name: 'negate', - kind: 'boolean', - propertyName: 'negate', - optionFlags: '--negate', - description: - 'Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping.', - }, - density: { - name: 'density', - kind: 'string', - propertyName: 'density', - optionFlags: '--density', - description: - 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image is unsharp, please try increasing density.', - }, - monochrome: { - name: 'monochrome', - kind: 'boolean', - propertyName: 'monochrome', - optionFlags: '--monochrome', - description: - 'Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel.', - }, - shave: { - name: 'shave', - kind: 'auto', - propertyName: 'shave', - optionFlags: '--shave', - description: - 'Shave pixels from the image edges. The value should be in the format `width` or `width`x`height` to specify the number of pixels to remove from each side.', - }, -} as const - -const documentConvertCommandFields = { - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: 'The desired format for document conversion.', - required: true, - }, - markdownFormat: { - name: 'markdown_format', - kind: 'string', - propertyName: 'markdownFormat', - optionFlags: '--markdown-format', - description: - 'Markdown can be represented in several [variants](https://www.iana.org/assignments/markdown-variants/markdown-variants.xhtml), so when using this Robot to transform Markdown into HTML please specify which revision is being used.', - }, - markdownTheme: { - name: 'markdown_theme', - kind: 'string', - propertyName: 'markdownTheme', - optionFlags: '--markdown-theme', - description: - 'This parameter overhauls your Markdown files styling based on several canned presets.', - }, - pdfMargin: { - name: 'pdf_margin', - kind: 'string', - propertyName: 'pdfMargin', - optionFlags: '--pdf-margin', - description: - 'PDF Paper margins, separated by `,` and with units.\n\nWe support the following unit values: `px`, `in`, `cm`, `mm`.\n\nCurrently this parameter is only supported when converting from `html`.', - }, - pdfPrintBackground: { - name: 'pdf_print_background', - kind: 'boolean', - propertyName: 'pdfPrintBackground', - optionFlags: '--pdf-print-background', - description: - 'Print PDF background graphics.\n\nCurrently this parameter is only supported when converting from `html`.', - }, - pdfFormat: { - name: 'pdf_format', - kind: 'string', - propertyName: 'pdfFormat', - optionFlags: '--pdf-format', - description: - 'PDF paper format.\n\nCurrently this parameter is only supported when converting from `html`.', - }, - pdfDisplayHeaderFooter: { - name: 'pdf_display_header_footer', - kind: 'boolean', - propertyName: 'pdfDisplayHeaderFooter', - optionFlags: '--pdf-display-header-footer', - description: - 'Display PDF header and footer.\n\nCurrently this parameter is only supported when converting from `html`.', - }, - pdfHeaderTemplate: { - name: 'pdf_header_template', - kind: 'string', - propertyName: 'pdfHeaderTemplate', - optionFlags: '--pdf-header-template', - description: - 'HTML template for the PDF print header.\n\nShould be valid HTML markup with following classes used to inject printing values into them:\n- `date` formatted print date\n- `title` document title\n- `url` document location\n- `pageNumber` current page number\n- `totalPages` total pages in the document\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number at the top of a page you\'d use the following HTML for the header template:\n\n```html\n
\n```', - }, - pdfFooterTemplate: { - name: 'pdf_footer_template', - kind: 'string', - propertyName: 'pdfFooterTemplate', - optionFlags: '--pdf-footer-template', - description: - 'HTML template for the PDF print footer.\n\nShould use the same format as the `pdf_header_template`.\n\nCurrently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled.\n\nTo change the formatting of the HTML element, the `font-size` must be specified in a wrapper. For example, to center the page number in the footer you\'d use the following HTML for the footer template:\n\n```html\n
\n```', - }, -} as const - -const documentOptimizeCommandFields = { - preset: { - name: 'preset', - kind: 'string', - propertyName: 'preset', - optionFlags: '--preset', - description: - 'The quality preset to use for optimization. Each preset provides a different balance between file size and quality:\n\n- `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI.\n- `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI.\n- `printer` - High quality suitable for printing. Images are kept at 300 DPI.\n- `prepress` - Highest quality for professional printing. Minimal compression applied.', - }, - imageDpi: { - name: 'image_dpi', - kind: 'number', - propertyName: 'imageDpi', - optionFlags: '--image-dpi', - description: - 'Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset.\n\nHigher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed.\n\nCommon values:\n- 72 - Screen viewing\n- 150 - eBooks and general documents\n- 300 - Print quality\n- 600 - High-quality print', - }, - compressFonts: { - name: 'compress_fonts', - kind: 'boolean', - propertyName: 'compressFonts', - optionFlags: '--compress-fonts', - description: - 'Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size.', - }, - subsetFonts: { - name: 'subset_fonts', - kind: 'boolean', - propertyName: 'subsetFonts', - optionFlags: '--subset-fonts', - description: - "Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. This can significantly reduce file size for documents that only use a small portion of a font's character set.", - }, - removeMetadata: { - name: 'remove_metadata', - kind: 'boolean', - propertyName: 'removeMetadata', - optionFlags: '--remove-metadata', - description: - 'Whether to strip document metadata (title, author, keywords, etc.) from the PDF. This can provide a small reduction in file size and may be useful for privacy.', - }, - linearize: { - name: 'linearize', - kind: 'boolean', - propertyName: 'linearize', - optionFlags: '--linearize', - description: - 'Whether to linearize (optimize for Fast Web View) the output PDF. Linearized PDFs can begin displaying in a browser before they are fully downloaded, improving the user experience for web delivery.', - }, - compatibility: { - name: 'compatibility', - kind: 'string', - propertyName: 'compatibility', - optionFlags: '--compatibility', - description: - 'The PDF version compatibility level. Lower versions have broader compatibility but fewer features. Higher versions support more advanced features but may not open in older PDF readers.\n\n- `1.4` - Acrobat 5 compatibility, most widely supported\n- `1.5` - Acrobat 6 compatibility\n- `1.6` - Acrobat 7 compatibility\n- `1.7` - Acrobat 8+ compatibility (default)\n- `2.0` - PDF 2.0 standard', - }, -} as const - -const documentThumbsCommandFields = { - page: { - name: 'page', - kind: 'number', - propertyName: 'page', - optionFlags: '--page', - description: - 'The PDF page that you want to convert to an image. By default the value is `null` which means that all pages will be converted into images.', - }, - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: - 'The format of the extracted image(s).\n\nIf you specify the value `"gif"`, then an animated gif cycling through all pages is created. Please check out [this demo](/demos/document-processing/convert-all-pages-of-a-document-into-an-animated-gif/) to learn more about this.', - }, - delay: { - name: 'delay', - kind: 'number', - propertyName: 'delay', - optionFlags: '--delay', - description: - 'If your output format is `"gif"` then this parameter sets the number of 100th seconds to pass before the next frame is shown in the animation. Set this to `100` for example to allow 1 second to pass between the frames of the animated gif.\n\nIf your output format is not `"gif"`, then this parameter does not have any effect.', - }, - width: { - name: 'width', - kind: 'number', - propertyName: 'width', - optionFlags: '--width', - description: - 'Width of the new image, in pixels. If not specified, will default to the width of the input image', - }, - height: { - name: 'height', - kind: 'number', - propertyName: 'height', - optionFlags: '--height', - description: - 'Height of the new image, in pixels. If not specified, will default to the height of the input image', - }, - resizeStrategy: { - name: 'resize_strategy', - kind: 'string', - propertyName: 'resizeStrategy', - optionFlags: '--resize-strategy', - description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', - }, - background: { - name: 'background', - kind: 'string', - propertyName: 'background', - optionFlags: '--background', - description: - 'Either the hexadecimal code or [name](https://www.imagemagick.org/script/color.php#color_names) of the color used to fill the background (only used for the pad resize strategy).\n\nBy default, the background of transparent images is changed to white. For details about how to preserve transparency across all image types, see [this demo](/demos/image-manipulation/properly-preserve-transparency-across-all-image-types/).', - }, - alpha: { - name: 'alpha', - kind: 'string', - propertyName: 'alpha', - optionFlags: '--alpha', - description: - 'Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency.\n\nFor a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha).', - }, - density: { - name: 'density', - kind: 'string', - propertyName: 'density', - optionFlags: '--density', - description: - 'While in-memory quality and file format depth specifies the color resolution, the density of an image is the spatial (space) resolution of the image. That is the density (in pixels per inch) of an image and defines how far apart (or how big) the individual pixels are. It defines the size of the image in real world terms when displayed on devices or printed.\n\nYou can set this value to a specific `width` or in the format `width`x`height`.\n\nIf your converted image has a low resolution, please try using the density parameter to resolve that.', - }, - antialiasing: { - name: 'antialiasing', - kind: 'boolean', - propertyName: 'antialiasing', - optionFlags: '--antialiasing', - description: - 'Controls whether or not antialiasing is used to remove jagged edges from text or images in a document.', - }, - colorspace: { - name: 'colorspace', - kind: 'string', - propertyName: 'colorspace', - optionFlags: '--colorspace', - description: - 'Sets the image colorspace. For details about the available values, see the [ImageMagick documentation](https://www.imagemagick.org/script/command-line-options.php#colorspace).\n\nPlease note that if you were using `"RGB"`, we recommend using `"sRGB"`. ImageMagick might try to find the most efficient `colorspace` based on the color of an image, and default to e.g. `"Gray"`. To force colors, you might then have to use this parameter.', - }, - trimWhitespace: { - name: 'trim_whitespace', - kind: 'boolean', - propertyName: 'trimWhitespace', - optionFlags: '--trim-whitespace', - description: - "This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. If you set this to `true` only the real PDF page contents will be shown in the image.\n\nIf you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`.", - }, - pdfUseCropbox: { - name: 'pdf_use_cropbox', - kind: 'boolean', - propertyName: 'pdfUseCropbox', - optionFlags: '--pdf-use-cropbox', - description: - "Some PDF documents lie about their dimensions. For instance they'll say they are landscape, but when opened in decent Desktop readers, it's really in portrait mode. This can happen if the document has a cropbox defined. When this option is enabled (by default), the cropbox is leading in determining the dimensions of the resulting thumbnails.", - }, - turbo: { - name: 'turbo', - kind: 'boolean', - propertyName: 'turbo', - optionFlags: '--turbo', - description: - "If you set this to `false`, the robot will not emit files as they become available. This is useful if you are only interested in the final result and not in the intermediate steps.\n\nAlso, extracted pages will be resized a lot faster as they are sent off to other machines for the resizing. This is especially useful for large documents with many pages to get up to 20 times faster processing.\n\nTurbo Mode increases pricing, though, in that the input document's file size is added for every extracted page. There are no performance benefits nor increased charges for single-page documents.", - }, -} as const - -const audioWaveformCommandFields = { - ffmpeg: { - name: 'ffmpeg', - kind: 'json', - propertyName: 'ffmpeg', - optionFlags: '--ffmpeg', - description: - 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', - }, - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: - 'The format of the result file. Can be `"image"` or `"json"`. If `"image"` is supplied, a PNG image will be created, otherwise a JSON file.', - }, - width: { - name: 'width', - kind: 'number', - propertyName: 'width', - optionFlags: '--width', - description: 'The width of the resulting image if the format `"image"` was selected.', - }, - height: { - name: 'height', - kind: 'number', - propertyName: 'height', - optionFlags: '--height', - description: 'The height of the resulting image if the format `"image"` was selected.', - }, - antialiasing: { - name: 'antialiasing', - kind: 'auto', - propertyName: 'antialiasing', - optionFlags: '--antialiasing', - description: - 'Either a value of `0` or `1`, or `true`/`false`, corresponding to if you want to enable antialiasing to achieve smoother edges in the waveform graph or not.', - }, - backgroundColor: { - name: 'background_color', - kind: 'string', - propertyName: 'backgroundColor', - optionFlags: '--background-color', - description: - 'The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected.', - }, - centerColor: { - name: 'center_color', - kind: 'string', - propertyName: 'centerColor', - optionFlags: '--center-color', - description: - 'The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', - }, - outerColor: { - name: 'outer_color', - kind: 'string', - propertyName: 'outerColor', - optionFlags: '--outer-color', - description: - 'The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha).', - }, - style: { - name: 'style', - kind: 'string', - propertyName: 'style', - optionFlags: '--style', - description: - 'Waveform style version.\n\n- `"v0"`: Legacy waveform generation (default).\n- `"v1"`: Advanced waveform generation with additional parameters.\n\nFor backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2).', - }, - splitChannels: { - name: 'split_channels', - kind: 'boolean', - propertyName: 'splitChannels', - optionFlags: '--split-channels', - description: - 'Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel.', - }, - zoom: { - name: 'zoom', - kind: 'number', - propertyName: 'zoom', - optionFlags: '--zoom', - description: - 'Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`.', - }, - pixelsPerSecond: { - name: 'pixels_per_second', - kind: 'number', - propertyName: 'pixelsPerSecond', - optionFlags: '--pixels-per-second', - description: - 'Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`.', - }, - bits: { - name: 'bits', - kind: 'number', - propertyName: 'bits', - optionFlags: '--bits', - description: 'Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16.', - }, - start: { - name: 'start', - kind: 'number', - propertyName: 'start', - optionFlags: '--start', - description: 'Available when style is `"v1"`. Start time in seconds.', - }, - end: { - name: 'end', - kind: 'number', - propertyName: 'end', - optionFlags: '--end', - description: 'Available when style is `"v1"`. End time in seconds (0 means end of audio).', - }, - colors: { - name: 'colors', - kind: 'string', - propertyName: 'colors', - optionFlags: '--colors', - description: - 'Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity".', - }, - borderColor: { - name: 'border_color', - kind: 'string', - propertyName: 'borderColor', - optionFlags: '--border-color', - description: 'Available when style is `"v1"`. Border color in "rrggbbaa" format.', - }, - waveformStyle: { - name: 'waveform_style', - kind: 'string', - propertyName: 'waveformStyle', - optionFlags: '--waveform-style', - description: 'Available when style is `"v1"`. Waveform style. Can be "normal" or "bars".', - }, - barWidth: { - name: 'bar_width', - kind: 'number', - propertyName: 'barWidth', - optionFlags: '--bar-width', - description: - 'Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars".', - }, - barGap: { - name: 'bar_gap', - kind: 'number', - propertyName: 'barGap', - optionFlags: '--bar-gap', - description: - 'Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars".', - }, - barStyle: { - name: 'bar_style', - kind: 'string', - propertyName: 'barStyle', - optionFlags: '--bar-style', - description: 'Available when style is `"v1"`. Bar style when waveform_style is "bars".', - }, - axisLabelColor: { - name: 'axis_label_color', - kind: 'string', - propertyName: 'axisLabelColor', - optionFlags: '--axis-label-color', - description: 'Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format.', - }, - noAxisLabels: { - name: 'no_axis_labels', - kind: 'boolean', - propertyName: 'noAxisLabels', - optionFlags: '--no-axis-labels', - description: - 'Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels.', - }, - withAxisLabels: { - name: 'with_axis_labels', - kind: 'boolean', - propertyName: 'withAxisLabels', - optionFlags: '--with-axis-labels', - description: - 'Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels.', - }, - amplitudeScale: { - name: 'amplitude_scale', - kind: 'number', - propertyName: 'amplitudeScale', - optionFlags: '--amplitude-scale', - description: 'Available when style is `"v1"`. Amplitude scale factor.', - }, - compression: { - name: 'compression', - kind: 'number', - propertyName: 'compression', - optionFlags: '--compression', - description: - 'Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image".', - }, -} as const - -const textSpeakCommandFields = { - prompt: { - name: 'prompt', - kind: 'string', - propertyName: 'prompt', - optionFlags: '--prompt', - description: - 'Which text to speak. You can also set this to `null` and supply an input text file.', - }, - provider: { - name: 'provider', - kind: 'string', - propertyName: 'provider', - optionFlags: '--provider', - description: - 'Which AI provider to leverage.\n\nTransloadit outsources this task and abstracts the interface so you can expect the same data structures, but different latencies and information being returned. Different cloud vendors have different areas they shine in, and we recommend to try out and see what yields the best results for your use case.', - required: true, - }, - targetLanguage: { - name: 'target_language', - kind: 'string', - propertyName: 'targetLanguage', - optionFlags: '--target-language', - description: - 'The written language of the document. This will also be the language of the spoken text.\n\nThe language should be specified in the [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) format, such as `"en-GB"`, `"de-DE"` or `"fr-FR"`. Please consult the list of supported languages and voices.', - }, - voice: { - name: 'voice', - kind: 'string', - propertyName: 'voice', - optionFlags: '--voice', - description: - 'The gender to be used for voice synthesis. Please consult the list of supported languages and voices.', - }, - ssml: { - name: 'ssml', - kind: 'boolean', - propertyName: 'ssml', - optionFlags: '--ssml', - description: - 'Supply [Speech Synthesis Markup Language](https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language) instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations.\n\nPlease see the supported syntaxes for [AWS](https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html) and [GCP](https://cloud.google.com/text-to-speech/docs/ssml).', - }, -} as const - -const videoThumbsCommandFields = { - ffmpeg: { - name: 'ffmpeg', - kind: 'json', - propertyName: 'ffmpeg', - optionFlags: '--ffmpeg', - description: - 'A parameter object to be passed to FFmpeg. If a preset is used, the options specified are merged on top of the ones from the preset. For available options, see the [FFmpeg documentation](https://ffmpeg.org/ffmpeg-doc.html). Options specified here take precedence over the preset options.', - }, - count: { - name: 'count', - kind: 'number', - propertyName: 'count', - optionFlags: '--count', - description: - 'The number of thumbnails to be extracted. As some videos have incorrect durations, the actual number of thumbnails generated may be less in rare cases. The maximum number of thumbnails we currently allow is 999.\n\nThe thumbnails are taken at regular intervals, determined by dividing the video duration by the count. For example, a count of 3 will produce thumbnails at 25%, 50% and 75% through the video.\n\nTo extract thumbnails for specific timestamps, use the `offsets` parameter.', - }, - offsets: { - name: 'offsets', - kind: 'json', - propertyName: 'offsets', - optionFlags: '--offsets', - description: - 'An array of offsets representing seconds of the file duration, such as `[ 2, 45, 120 ]`. Millisecond durations of a file can also be used by using decimal place values. For example, an offset from 1250 milliseconds would be represented with `1.25`. Offsets can also be percentage values such as `[ "2%", "50%", "75%" ]`.\n\nThis option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored.', - }, - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: - 'The format of the extracted thumbnail. Supported values are `"jpg"`, `"jpeg"` and `"png"`. Even if you specify the format to be `"jpeg"` the resulting thumbnails will have a `"jpg"` file extension.', - }, - width: { - name: 'width', - kind: 'number', - propertyName: 'width', - optionFlags: '--width', - description: - 'The width of the thumbnail, in pixels. Defaults to the original width of the video.', - }, - height: { - name: 'height', - kind: 'number', - propertyName: 'height', - optionFlags: '--height', - description: - 'The height of the thumbnail, in pixels. Defaults to the original height of the video.', - }, - resizeStrategy: { - name: 'resize_strategy', - kind: 'string', - propertyName: 'resizeStrategy', - optionFlags: '--resize-strategy', - description: 'One of the [available resize strategies](/docs/topics/resize-strategies/).', - }, - background: { - name: 'background', - kind: 'string', - propertyName: 'background', - optionFlags: '--background', - description: - 'The background color of the resulting thumbnails in the `"rrggbbaa"` format (red, green, blue, alpha) when used with the `"pad"` resize strategy. The default color is black.', - }, - rotate: { - name: 'rotate', - kind: 'number', - propertyName: 'rotate', - optionFlags: '--rotate', - description: - 'Forces the video to be rotated by the specified degree integer. Currently, only multiples of 90 are supported. We automatically correct the orientation of many videos when the orientation is provided by the camera. This option is only useful for videos requiring rotation because it was not detected by the camera.', - }, - inputCodec: { - name: 'input_codec', - kind: 'string', - propertyName: 'inputCodec', - optionFlags: '--input-codec', - description: - 'Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders.', - }, -} as const - -const imageDescribeCommandFields = { - fields: { - name: 'fields', - kind: 'string-array', - propertyName: 'fields', - optionFlags: '--fields', - description: - 'Describe output fields to generate, for example labels or altText,title,caption,description', - }, - forProfile: { - name: 'forProfile', - kind: 'string', - propertyName: 'forProfile', - optionFlags: '--for', - description: 'Use a named output profile, currently: wordpress', - }, - model: { - name: 'model', - kind: 'string', - propertyName: 'model', - optionFlags: '--model', - description: 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', - }, -} as const - -const fileCompressCommandFields = { - format: { - name: 'format', - kind: 'string', - propertyName: 'format', - optionFlags: '--format', - description: - 'The format of the archive to be created. Supported values are `"tar"` and `"zip"`.\n\nNote that `"tar"` without setting `gzip` to `true` results in an archive that\'s not compressed in any way.', - }, - gzip: { - name: 'gzip', - kind: 'boolean', - propertyName: 'gzip', - optionFlags: '--gzip', - description: - 'Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format.', - }, - password: { - name: 'password', - kind: 'string', - propertyName: 'password', - optionFlags: '--password', - description: - 'This allows you to encrypt all archive contents with a password and thereby protect it against unauthorized use. To unzip the archive, the user will need to provide the password in a text input field prompt.\n\nThis parameter has no effect if the format parameter is anything other than `"zip"`.', - }, - compressionLevel: { - name: 'compression_level', - kind: 'number', - propertyName: 'compressionLevel', - optionFlags: '--compression-level', - description: - 'Determines how fiercely to try to compress the archive. `-0` is compressionless, which is suitable for media that is already compressed. `-1` is fastest with lowest compression. `-9` is slowest with the highest compression.\n\nIf you are using `-0` in combination with the `tar` format with `gzip` enabled, consider setting `gzip: false` instead. This results in a plain Tar archive, meaning it already has no compression.', - }, - fileLayout: { - name: 'file_layout', - kind: 'string', - propertyName: 'fileLayout', - optionFlags: '--file-layout', - description: - 'Determines if the result archive should contain all files in one directory (value for this is `"simple"`) or in subfolders according to the explanation below (value for this is `"advanced"`). The `"relative-path"` option preserves the relative directory structure of the input files.\n\nFiles with same names are numbered in the `"simple"` file layout to avoid naming collisions.', - }, - archiveName: { - name: 'archive_name', - kind: 'string', - propertyName: 'archiveName', - optionFlags: '--archive-name', - description: 'The name of the archive file to be created (without the file extension).', - }, -} as const -const imageGenerateCommandDefinition = { - outputMode: 'file', - outputDescription: 'Write the result to this path', - execution: { - kind: 'single-step', - schema: robotImageGenerateInstructionsSchema, - fields: Object.values(imageGenerateCommandFields), - fixedValues: { - robot: '/image/generate', - result: true, - }, - resultStepName: 'generate', - }, -} as const - -const previewGenerateCommandDefinition = { - commandLabel: 'preview generate', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotFilePreviewInstructionsSchema, - fields: Object.values(previewGenerateCommandFields), - fixedValues: { - robot: '/file/preview', - result: true, - use: ':original', - }, - resultStepName: 'generate', - }, -} as const - -const imageRemoveBackgroundCommandDefinition = { - commandLabel: 'image remove-background', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotImageBgremoveInstructionsSchema, - fields: Object.values(imageRemoveBackgroundCommandFields), - fixedValues: { - robot: '/image/bgremove', - result: true, - use: ':original', - }, - resultStepName: 'remove_background', - }, -} as const - -const imageOptimizeCommandDefinition = { - commandLabel: 'image optimize', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotImageOptimizeInstructionsSchema, - fields: Object.values(imageOptimizeCommandFields), - fixedValues: { - robot: '/image/optimize', - result: true, - use: ':original', - }, - resultStepName: 'optimize', - }, -} as const - -const imageResizeCommandDefinition = { - commandLabel: 'image resize', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotImageResizeInstructionsSchema, - fields: Object.values(imageResizeCommandFields), - fixedValues: { - robot: '/image/resize', - result: true, - use: ':original', - }, - resultStepName: 'resize', - }, -} as const - -const documentConvertCommandDefinition = { - commandLabel: 'document convert', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotDocumentConvertInstructionsSchema, - fields: Object.values(documentConvertCommandFields), - fixedValues: { - robot: '/document/convert', - result: true, - use: ':original', - }, - resultStepName: 'convert', - }, -} as const - -const documentOptimizeCommandDefinition = { - commandLabel: 'document optimize', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotDocumentOptimizeInstructionsSchema, - fields: Object.values(documentOptimizeCommandFields), - fixedValues: { - robot: '/document/optimize', - result: true, - use: ':original', - }, - resultStepName: 'optimize', - }, -} as const - -const documentAutoRotateCommandDefinition = { - commandLabel: 'document auto-rotate', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotDocumentAutorotateInstructionsSchema, - fields: [], - fixedValues: { - robot: '/document/autorotate', - result: true, - use: ':original', - }, - resultStepName: 'auto_rotate', - }, -} as const - -const documentThumbsCommandDefinition = { - commandLabel: 'document thumbs', - inputPolicy: { - kind: 'required', - }, - outputMode: 'directory', - outputDescription: 'Write the results to this directory', - execution: { - kind: 'single-step', - schema: robotDocumentThumbsInstructionsSchema, - fields: Object.values(documentThumbsCommandFields), - fixedValues: { - robot: '/document/thumbs', - result: true, - use: ':original', - }, - resultStepName: 'thumbs', - }, -} as const - -const audioWaveformCommandDefinition = { - commandLabel: 'audio waveform', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotAudioWaveformInstructionsSchema, - fields: Object.values(audioWaveformCommandFields), - fixedValues: { - robot: '/audio/waveform', - result: true, - use: ':original', - }, - resultStepName: 'waveform', - }, -} as const - -const textSpeakCommandDefinition = { - commandLabel: 'text speak', - inputPolicy: { - kind: 'optional', - field: 'prompt', - attachUseWhenInputsProvided: true, - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotTextSpeakInstructionsSchema, - fields: Object.values(textSpeakCommandFields), - fixedValues: { - robot: '/text/speak', - result: true, - }, - resultStepName: 'speak', - }, -} as const - -const videoThumbsCommandDefinition = { - commandLabel: 'video thumbs', - inputPolicy: { - kind: 'required', - }, - outputMode: 'directory', - outputDescription: 'Write the results to this directory', - execution: { - kind: 'single-step', - schema: robotVideoThumbsInstructionsSchema, - fields: Object.values(videoThumbsCommandFields), - fixedValues: { - robot: '/video/thumbs', - result: true, - use: ':original', - }, - resultStepName: 'thumbs', - }, -} as const - -const videoEncodeHlsCommandDefinition = { - commandLabel: 'video encode-hls', - inputPolicy: { kind: 'required' }, - outputMode: 'directory', - outputDescription: 'Write the results to this directory', - execution: { - kind: 'template', - templateId: 'builtin/encode-hls-video@latest', - }, -} as const - -const imageDescribeCommandDefinition = { - commandLabel: 'image describe', - inputPolicy: { - kind: 'required', - }, - outputDescription: 'Write the JSON result to this path or directory', - execution: { - kind: 'dynamic-step', - handler: 'image-describe', - fields: Object.values(imageDescribeCommandFields), - resultStepName: 'describe', - }, -} as const - -const fileCompressCommandDefinition = { - commandLabel: 'file compress', - inputPolicy: { - kind: 'required', - }, - outputMode: 'file', - outputDescription: 'Write the result to this path or directory', - execution: { - kind: 'single-step', - schema: robotFileCompressInstructionsSchema, - fields: Object.values(fileCompressCommandFields), - fixedValues: { - robot: '/file/compress', - result: true, - use: { - steps: [':original'], - bundle_steps: true, - }, - }, - resultStepName: 'compress', - }, -} as const - -const fileDecompressCommandDefinition = { - commandLabel: 'file decompress', - inputPolicy: { - kind: 'required', - }, - outputMode: 'directory', - outputDescription: 'Write the results to this directory', - execution: { - kind: 'single-step', - schema: robotFileDecompressInstructionsSchema, - fields: [], - fixedValues: { - robot: '/file/decompress', - result: true, - use: ':original', - }, - resultStepName: 'decompress', - }, -} as const - -class ImageGenerateCommand extends GeneratedNoInputIntentCommand { - static override paths = [['image', 'generate']] - - static override intentDefinition = imageGenerateCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Generate images from text prompts', - details: 'Runs `/image/generate` and writes the result to `--out`.', - examples: [ - [ - 'Run the command', - 'transloadit image generate --prompt "A red bicycle in a studio" --out output.png', - ], - ], - }) - - model = createIntentOption(imageGenerateCommandFields.model) - - prompt = createIntentOption(imageGenerateCommandFields.prompt) - - format = createIntentOption(imageGenerateCommandFields.format) - - seed = createIntentOption(imageGenerateCommandFields.seed) - - aspectRatio = createIntentOption(imageGenerateCommandFields.aspectRatio) - - height = createIntentOption(imageGenerateCommandFields.height) - - width = createIntentOption(imageGenerateCommandFields.width) - - style = createIntentOption(imageGenerateCommandFields.style) - - numOutputs = createIntentOption(imageGenerateCommandFields.numOutputs) -} - -class PreviewGenerateCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['preview', 'generate']] - - static override intentDefinition = previewGenerateCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Generate a preview thumbnail', - details: 'Runs `/file/preview` on each input file and writes the result to `--out`.', - examples: [ - ['Run the command', 'transloadit preview generate --input input.file --out output.file'], - ], - }) - - format = createIntentOption(previewGenerateCommandFields.format) - - width = createIntentOption(previewGenerateCommandFields.width) - - height = createIntentOption(previewGenerateCommandFields.height) - - resizeStrategy = createIntentOption(previewGenerateCommandFields.resizeStrategy) - - background = createIntentOption(previewGenerateCommandFields.background) - - strategy = createIntentOption(previewGenerateCommandFields.strategy) - - artworkOuterColor = createIntentOption(previewGenerateCommandFields.artworkOuterColor) - - artworkCenterColor = createIntentOption(previewGenerateCommandFields.artworkCenterColor) - - waveformCenterColor = createIntentOption(previewGenerateCommandFields.waveformCenterColor) - - waveformOuterColor = createIntentOption(previewGenerateCommandFields.waveformOuterColor) - - waveformHeight = createIntentOption(previewGenerateCommandFields.waveformHeight) - - waveformWidth = createIntentOption(previewGenerateCommandFields.waveformWidth) - - iconStyle = createIntentOption(previewGenerateCommandFields.iconStyle) - - iconTextColor = createIntentOption(previewGenerateCommandFields.iconTextColor) - - iconTextFont = createIntentOption(previewGenerateCommandFields.iconTextFont) - - iconTextContent = createIntentOption(previewGenerateCommandFields.iconTextContent) - - optimize = createIntentOption(previewGenerateCommandFields.optimize) - - optimizePriority = createIntentOption(previewGenerateCommandFields.optimizePriority) - - optimizeProgressive = createIntentOption(previewGenerateCommandFields.optimizeProgressive) - - clipFormat = createIntentOption(previewGenerateCommandFields.clipFormat) - - clipOffset = createIntentOption(previewGenerateCommandFields.clipOffset) - - clipDuration = createIntentOption(previewGenerateCommandFields.clipDuration) - - clipFramerate = createIntentOption(previewGenerateCommandFields.clipFramerate) - - clipLoop = createIntentOption(previewGenerateCommandFields.clipLoop) -} - -class ImageRemoveBackgroundCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['image', 'remove-background']] - - static override intentDefinition = imageRemoveBackgroundCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Remove the background from images', - details: 'Runs `/image/bgremove` on each input file and writes the result to `--out`.', - examples: [ - ['Run the command', 'transloadit image remove-background --input input.png --out output.png'], - ], - }) - - select = createIntentOption(imageRemoveBackgroundCommandFields.select) - - format = createIntentOption(imageRemoveBackgroundCommandFields.format) - - provider = createIntentOption(imageRemoveBackgroundCommandFields.provider) - - model = createIntentOption(imageRemoveBackgroundCommandFields.model) -} - -class ImageOptimizeCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['image', 'optimize']] - - static override intentDefinition = imageOptimizeCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Optimize images without quality loss', - details: 'Runs `/image/optimize` on each input file and writes the result to `--out`.', - examples: [ - ['Run the command', 'transloadit image optimize --input input.png --out output.png'], - ], - }) - - priority = createIntentOption(imageOptimizeCommandFields.priority) - - progressive = createIntentOption(imageOptimizeCommandFields.progressive) - - preserveMetaData = createIntentOption(imageOptimizeCommandFields.preserveMetaData) - - fixBreakingImages = createIntentOption(imageOptimizeCommandFields.fixBreakingImages) -} - -class ImageResizeCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['image', 'resize']] - - static override intentDefinition = imageResizeCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Convert, resize, or watermark images', - details: 'Runs `/image/resize` on each input file and writes the result to `--out`.', - examples: [['Run the command', 'transloadit image resize --input input.png --out output.png']], - }) - - format = createIntentOption(imageResizeCommandFields.format) - - width = createIntentOption(imageResizeCommandFields.width) - - height = createIntentOption(imageResizeCommandFields.height) - - resizeStrategy = createIntentOption(imageResizeCommandFields.resizeStrategy) - - zoom = createIntentOption(imageResizeCommandFields.zoom) - - crop = createIntentOption(imageResizeCommandFields.crop) - - gravity = createIntentOption(imageResizeCommandFields.gravity) - - strip = createIntentOption(imageResizeCommandFields.strip) - - alpha = createIntentOption(imageResizeCommandFields.alpha) - - preclipAlpha = createIntentOption(imageResizeCommandFields.preclipAlpha) - - flatten = createIntentOption(imageResizeCommandFields.flatten) - - correctGamma = createIntentOption(imageResizeCommandFields.correctGamma) - - quality = createIntentOption(imageResizeCommandFields.quality) - - adaptiveFiltering = createIntentOption(imageResizeCommandFields.adaptiveFiltering) - - background = createIntentOption(imageResizeCommandFields.background) - - frame = createIntentOption(imageResizeCommandFields.frame) - - colorspace = createIntentOption(imageResizeCommandFields.colorspace) - - type = createIntentOption(imageResizeCommandFields.type) - - sepia = createIntentOption(imageResizeCommandFields.sepia) - - rotation = createIntentOption(imageResizeCommandFields.rotation) - - compress = createIntentOption(imageResizeCommandFields.compress) - - blur = createIntentOption(imageResizeCommandFields.blur) - - blurRegions = createIntentOption(imageResizeCommandFields.blurRegions) - - brightness = createIntentOption(imageResizeCommandFields.brightness) - - saturation = createIntentOption(imageResizeCommandFields.saturation) - - hue = createIntentOption(imageResizeCommandFields.hue) - - contrast = createIntentOption(imageResizeCommandFields.contrast) - - watermarkUrl = createIntentOption(imageResizeCommandFields.watermarkUrl) - - watermarkPosition = createIntentOption(imageResizeCommandFields.watermarkPosition) - - watermarkXOffset = createIntentOption(imageResizeCommandFields.watermarkXOffset) - - watermarkYOffset = createIntentOption(imageResizeCommandFields.watermarkYOffset) - - watermarkSize = createIntentOption(imageResizeCommandFields.watermarkSize) - - watermarkResizeStrategy = createIntentOption(imageResizeCommandFields.watermarkResizeStrategy) - - watermarkOpacity = createIntentOption(imageResizeCommandFields.watermarkOpacity) - - watermarkRepeatX = createIntentOption(imageResizeCommandFields.watermarkRepeatX) - - watermarkRepeatY = createIntentOption(imageResizeCommandFields.watermarkRepeatY) - - text = createIntentOption(imageResizeCommandFields.text) - - progressive = createIntentOption(imageResizeCommandFields.progressive) - - transparent = createIntentOption(imageResizeCommandFields.transparent) - - trimWhitespace = createIntentOption(imageResizeCommandFields.trimWhitespace) - - clip = createIntentOption(imageResizeCommandFields.clip) - - negate = createIntentOption(imageResizeCommandFields.negate) - - density = createIntentOption(imageResizeCommandFields.density) - - monochrome = createIntentOption(imageResizeCommandFields.monochrome) - - shave = createIntentOption(imageResizeCommandFields.shave) -} - -class DocumentConvertCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['document', 'convert']] - - static override intentDefinition = documentConvertCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Convert documents into different formats', - details: 'Runs `/document/convert` on each input file and writes the result to `--out`.', - examples: [ - [ - 'Run the command', - 'transloadit document convert --input input.pdf --format pdf --out output.pdf', - ], - ], - }) - - format = createIntentOption(documentConvertCommandFields.format) - - markdownFormat = createIntentOption(documentConvertCommandFields.markdownFormat) - - markdownTheme = createIntentOption(documentConvertCommandFields.markdownTheme) - - pdfMargin = createIntentOption(documentConvertCommandFields.pdfMargin) - - pdfPrintBackground = createIntentOption(documentConvertCommandFields.pdfPrintBackground) - - pdfFormat = createIntentOption(documentConvertCommandFields.pdfFormat) - - pdfDisplayHeaderFooter = createIntentOption(documentConvertCommandFields.pdfDisplayHeaderFooter) - - pdfHeaderTemplate = createIntentOption(documentConvertCommandFields.pdfHeaderTemplate) - - pdfFooterTemplate = createIntentOption(documentConvertCommandFields.pdfFooterTemplate) -} - -class DocumentOptimizeCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['document', 'optimize']] - - static override intentDefinition = documentOptimizeCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Reduce PDF file size', - details: 'Runs `/document/optimize` on each input file and writes the result to `--out`.', - examples: [ - ['Run the command', 'transloadit document optimize --input input.pdf --out output.pdf'], - ], - }) - - preset = createIntentOption(documentOptimizeCommandFields.preset) - - imageDpi = createIntentOption(documentOptimizeCommandFields.imageDpi) - - compressFonts = createIntentOption(documentOptimizeCommandFields.compressFonts) - - subsetFonts = createIntentOption(documentOptimizeCommandFields.subsetFonts) - - removeMetadata = createIntentOption(documentOptimizeCommandFields.removeMetadata) - - linearize = createIntentOption(documentOptimizeCommandFields.linearize) - - compatibility = createIntentOption(documentOptimizeCommandFields.compatibility) -} - -class DocumentAutoRotateCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['document', 'auto-rotate']] - - static override intentDefinition = documentAutoRotateCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Auto-rotate documents to the correct orientation', - details: 'Runs `/document/autorotate` on each input file and writes the result to `--out`.', - examples: [ - ['Run the command', 'transloadit document auto-rotate --input input.pdf --out output.pdf'], - ], - }) -} - -class DocumentThumbsCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['document', 'thumbs']] - - static override intentDefinition = documentThumbsCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Extract thumbnail images from documents', - details: 'Runs `/document/thumbs` on each input file and writes the results to `--out`.', - examples: [['Run the command', 'transloadit document thumbs --input input.pdf --out output/']], - }) - - page = createIntentOption(documentThumbsCommandFields.page) - - format = createIntentOption(documentThumbsCommandFields.format) - - delay = createIntentOption(documentThumbsCommandFields.delay) - - width = createIntentOption(documentThumbsCommandFields.width) - - height = createIntentOption(documentThumbsCommandFields.height) - - resizeStrategy = createIntentOption(documentThumbsCommandFields.resizeStrategy) - - background = createIntentOption(documentThumbsCommandFields.background) - - alpha = createIntentOption(documentThumbsCommandFields.alpha) - - density = createIntentOption(documentThumbsCommandFields.density) - - antialiasing = createIntentOption(documentThumbsCommandFields.antialiasing) - - colorspace = createIntentOption(documentThumbsCommandFields.colorspace) - - trimWhitespace = createIntentOption(documentThumbsCommandFields.trimWhitespace) - - pdfUseCropbox = createIntentOption(documentThumbsCommandFields.pdfUseCropbox) - - turbo = createIntentOption(documentThumbsCommandFields.turbo) -} - -class AudioWaveformCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['audio', 'waveform']] - - static override intentDefinition = audioWaveformCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Generate waveform images from audio', - details: 'Runs `/audio/waveform` on each input file and writes the result to `--out`.', - examples: [ - ['Run the command', 'transloadit audio waveform --input input.mp3 --out output.png'], - ], - }) - - ffmpeg = createIntentOption(audioWaveformCommandFields.ffmpeg) - - format = createIntentOption(audioWaveformCommandFields.format) - - width = createIntentOption(audioWaveformCommandFields.width) - - height = createIntentOption(audioWaveformCommandFields.height) - - antialiasing = createIntentOption(audioWaveformCommandFields.antialiasing) - - backgroundColor = createIntentOption(audioWaveformCommandFields.backgroundColor) - - centerColor = createIntentOption(audioWaveformCommandFields.centerColor) - - outerColor = createIntentOption(audioWaveformCommandFields.outerColor) - - style = createIntentOption(audioWaveformCommandFields.style) - - splitChannels = createIntentOption(audioWaveformCommandFields.splitChannels) - - zoom = createIntentOption(audioWaveformCommandFields.zoom) - - pixelsPerSecond = createIntentOption(audioWaveformCommandFields.pixelsPerSecond) - - bits = createIntentOption(audioWaveformCommandFields.bits) - - start = createIntentOption(audioWaveformCommandFields.start) - - end = createIntentOption(audioWaveformCommandFields.end) - - colors = createIntentOption(audioWaveformCommandFields.colors) - - borderColor = createIntentOption(audioWaveformCommandFields.borderColor) - - waveformStyle = createIntentOption(audioWaveformCommandFields.waveformStyle) - - barWidth = createIntentOption(audioWaveformCommandFields.barWidth) - - barGap = createIntentOption(audioWaveformCommandFields.barGap) - - barStyle = createIntentOption(audioWaveformCommandFields.barStyle) - - axisLabelColor = createIntentOption(audioWaveformCommandFields.axisLabelColor) - - noAxisLabels = createIntentOption(audioWaveformCommandFields.noAxisLabels) - - withAxisLabels = createIntentOption(audioWaveformCommandFields.withAxisLabels) - - amplitudeScale = createIntentOption(audioWaveformCommandFields.amplitudeScale) - - compression = createIntentOption(audioWaveformCommandFields.compression) -} - -class TextSpeakCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['text', 'speak']] - - static override intentDefinition = textSpeakCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Speak text', - details: 'Runs `/text/speak` on each input file and writes the result to `--out`.', - examples: [ - [ - 'Run the command', - 'transloadit text speak --input input.pdf --provider aws --out output.mp3', - ], - ], - }) - - prompt = createIntentOption(textSpeakCommandFields.prompt) - - provider = createIntentOption(textSpeakCommandFields.provider) - - targetLanguage = createIntentOption(textSpeakCommandFields.targetLanguage) - - voice = createIntentOption(textSpeakCommandFields.voice) - - ssml = createIntentOption(textSpeakCommandFields.ssml) -} - -class VideoThumbsCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['video', 'thumbs']] - - static override intentDefinition = videoThumbsCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Extract thumbnails from videos', - details: 'Runs `/video/thumbs` on each input file and writes the results to `--out`.', - examples: [['Run the command', 'transloadit video thumbs --input input.mp4 --out output/']], - }) - - ffmpeg = createIntentOption(videoThumbsCommandFields.ffmpeg) - - count = createIntentOption(videoThumbsCommandFields.count) - - offsets = createIntentOption(videoThumbsCommandFields.offsets) - - format = createIntentOption(videoThumbsCommandFields.format) - - width = createIntentOption(videoThumbsCommandFields.width) - - height = createIntentOption(videoThumbsCommandFields.height) - - resizeStrategy = createIntentOption(videoThumbsCommandFields.resizeStrategy) - - background = createIntentOption(videoThumbsCommandFields.background) - - rotate = createIntentOption(videoThumbsCommandFields.rotate) - - inputCodec = createIntentOption(videoThumbsCommandFields.inputCodec) -} - -class VideoEncodeHlsCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['video', 'encode-hls']] - - static override intentDefinition = videoEncodeHlsCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Run builtin/encode-hls-video@latest', - details: - 'Runs the `builtin/encode-hls-video@latest` template and writes the outputs to `--out`.', - examples: [['Run the command', 'transloadit video encode-hls --input input.mp4 --out output/']], - }) -} - -class ImageDescribeCommand extends GeneratedWatchableFileIntentCommand { - static override paths = [['image', 'describe']] - - static override intentDefinition = imageDescribeCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Describe images as labels or publishable text fields', - details: - 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', - examples: [ - [ - 'Describe an image as labels', - 'transloadit image describe --input hero.jpg --out labels.json', - ], - [ - 'Generate WordPress-ready fields', - 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', - ], - [ - 'Request a custom field set', - 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', - ], - ], - }) - - fields = createIntentOption(imageDescribeCommandFields.fields) - - forProfile = createIntentOption(imageDescribeCommandFields.forProfile) - - model = createIntentOption(imageDescribeCommandFields.model) -} - -class FileCompressCommand extends GeneratedBundledFileIntentCommand { - static override paths = [['file', 'compress']] - - static override intentDefinition = fileCompressCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Compress files', - details: 'Runs `/file/compress` for the provided inputs and writes the result to `--out`.', - examples: [ - ['Run the command', 'transloadit file compress --input input.file --out archive.zip'], - ], - }) - - format = createIntentOption(fileCompressCommandFields.format) - - gzip = createIntentOption(fileCompressCommandFields.gzip) - - password = createIntentOption(fileCompressCommandFields.password) - - compressionLevel = createIntentOption(fileCompressCommandFields.compressionLevel) - - fileLayout = createIntentOption(fileCompressCommandFields.fileLayout) - - archiveName = createIntentOption(fileCompressCommandFields.archiveName) -} - -class FileDecompressCommand extends GeneratedStandardFileIntentCommand { - static override paths = [['file', 'decompress']] - - static override intentDefinition = fileDecompressCommandDefinition - - static override usage = Command.Usage({ - category: 'Intent Commands', - description: 'Decompress archives', - details: 'Runs `/file/decompress` on each input file and writes the results to `--out`.', - examples: [['Run the command', 'transloadit file decompress --input input.file --out output/']], - }) -} - -export const intentCommands = [ - ImageGenerateCommand, - PreviewGenerateCommand, - ImageRemoveBackgroundCommand, - ImageOptimizeCommand, - ImageResizeCommand, - DocumentConvertCommand, - DocumentOptimizeCommand, - DocumentAutoRotateCommand, - DocumentThumbsCommand, - AudioWaveformCommand, - TextSpeakCommand, - VideoThumbsCommand, - VideoEncodeHlsCommand, - ImageDescribeCommand, - FileCompressCommand, - FileDecompressCommand, -] as const diff --git a/packages/node/src/cli/commands/index.ts b/packages/node/src/cli/commands/index.ts index 5abcbaf3..b76456ee 100644 --- a/packages/node/src/cli/commands/index.ts +++ b/packages/node/src/cli/commands/index.ts @@ -1,7 +1,7 @@ import { Builtins, Cli } from 'clipanion' import packageJson from '../../../package.json' with { type: 'json' } - +import { intentCommands } from '../intentCommands.ts' import { AssembliesCreateCommand, AssembliesDeleteCommand, @@ -10,12 +10,9 @@ import { AssembliesListCommand, AssembliesReplayCommand, } from './assemblies.ts' - import { SignatureCommand, SmartCdnSignatureCommand, TokenCommand } from './auth.ts' - import { BillsGetCommand } from './bills.ts' import { DocsRobotsGetCommand, DocsRobotsListCommand } from './docs.ts' -import { intentCommands } from './generated-intents.ts' import { NotificationsReplayCommand } from './notifications.ts' import { TemplatesCreateCommand, diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts new file mode 100644 index 00000000..235b9a2d --- /dev/null +++ b/packages/node/src/cli/intentCommands.ts @@ -0,0 +1,116 @@ +import type { CommandClass } from 'clipanion' +import { Command } from 'clipanion' + +import type { + GeneratedSchemaField, + ResolvedIntentCommandSpec, +} from './intentResolvedDefinitions.ts' +import { resolveIntentCommandSpecs } from './intentResolvedDefinitions.ts' +import { + createIntentOption, + GeneratedBundledFileIntentCommand, + GeneratedNoInputIntentCommand, + GeneratedStandardFileIntentCommand, + GeneratedWatchableFileIntentCommand, +} from './intentRuntime.ts' + +type IntentBaseClass = + | typeof GeneratedBundledFileIntentCommand + | typeof GeneratedNoInputIntentCommand + | typeof GeneratedStandardFileIntentCommand + | typeof GeneratedWatchableFileIntentCommand + +function getOptionFields(spec: ResolvedIntentCommandSpec): readonly GeneratedSchemaField[] { + if (spec.execution.kind === 'dynamic-step') { + return spec.execution.fields + } + + return spec.fieldSpecs +} + +function getBaseClass(spec: ResolvedIntentCommandSpec): IntentBaseClass { + if (spec.runnerKind === 'no-input') { + return GeneratedNoInputIntentCommand + } + + if (spec.runnerKind === 'bundled') { + return GeneratedBundledFileIntentCommand + } + + if (spec.runnerKind === 'watchable') { + return GeneratedWatchableFileIntentCommand + } + + return GeneratedStandardFileIntentCommand +} + +function createIntentCommandClass(spec: ResolvedIntentCommandSpec): CommandClass { + const BaseClass = getBaseClass(spec) + + class RuntimeIntentCommand extends BaseClass {} + + Object.defineProperty(RuntimeIntentCommand, 'name', { + value: spec.className, + }) + + Object.assign(RuntimeIntentCommand, { + paths: [spec.paths], + intentDefinition: + spec.execution.kind === 'single-step' + ? { + commandLabel: spec.commandLabel, + inputPolicy: spec.input.kind === 'local-files' ? spec.input.inputPolicy : undefined, + outputDescription: spec.outputDescription, + outputMode: spec.outputMode, + execution: { + kind: 'single-step', + schema: spec.schemaSpec?.schema, + fields: spec.fieldSpecs, + fixedValues: spec.execution.fixedValues, + resultStepName: spec.execution.resultStepName, + }, + } + : spec.execution.kind === 'dynamic-step' + ? { + commandLabel: spec.commandLabel, + inputPolicy: spec.input.kind === 'local-files' ? spec.input.inputPolicy : undefined, + outputDescription: spec.outputDescription, + outputMode: spec.outputMode, + execution: { + kind: 'dynamic-step', + handler: spec.execution.handler, + fields: spec.execution.fields, + resultStepName: spec.execution.resultStepName, + }, + } + : { + commandLabel: spec.commandLabel, + inputPolicy: spec.input.kind === 'local-files' ? spec.input.inputPolicy : undefined, + outputDescription: spec.outputDescription, + outputMode: spec.outputMode, + execution: { + kind: 'template', + templateId: spec.execution.templateId, + }, + }, + usage: Command.Usage({ + category: 'Intent Commands', + description: spec.description, + details: spec.details, + examples: spec.examples, + }), + }) + + for (const field of getOptionFields(spec)) { + Object.defineProperty(RuntimeIntentCommand.prototype, field.propertyName, { + configurable: true, + enumerable: true, + writable: true, + value: createIntentOption(field), + }) + } + + return RuntimeIntentCommand as unknown as CommandClass +} + +export const intentCommands = resolveIntentCommandSpecs().map(createIntentCommandClass) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 677bf443..2e7c0b63 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -5,13 +5,13 @@ import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' import * as assembliesCommands from '../../../src/cli/commands/assemblies.ts' -import { intentCommands } from '../../../src/cli/commands/generated-intents.ts' import { findIntentDefinitionByPaths, getIntentPaths, getIntentResultStepName, intentCatalog, } from '../../../src/cli/intentCommandSpecs.ts' +import { intentCommands } from '../../../src/cli/intentCommands.ts' import { coerceIntentFieldValue } from '../../../src/cli/intentFields.ts' import { prepareIntentInputs } from '../../../src/cli/intentRuntime.ts' import { intentSmokeCases } from '../../../src/cli/intentSmokeCases.ts' diff --git a/scripts/prepare-transloadit.ts b/scripts/prepare-transloadit.ts index 418de1f4..64ecb8c7 100644 --- a/scripts/prepare-transloadit.ts +++ b/scripts/prepare-transloadit.ts @@ -43,10 +43,7 @@ function replaceRequired( function deriveLegacyScripts(nodeScripts: Record): Record { const scripts = { ...nodeScripts } - delete scripts['sync:intents'] - if (scripts.check != null) { - scripts.check = replaceRequired(scripts.check, 'yarn sync:intents && ', '', 'scripts.check') scripts.check = replaceRequired(scripts.check, ' && yarn fix', '', 'scripts.check') } From 8aeb8090ade59b5d988740c5c477fc6e7f4b3010 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 14:41:58 +0200 Subject: [PATCH 32/44] refactor(node): inline intent resolution --- packages/node/src/cli/intentCommands.ts | 550 +++++++++++++++- .../node/src/cli/intentResolvedDefinitions.ts | 602 ------------------ 2 files changed, 545 insertions(+), 607 deletions(-) delete mode 100644 packages/node/src/cli/intentResolvedDefinitions.ts diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index 235b9a2d..dcac6e2a 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -1,11 +1,20 @@ import type { CommandClass } from 'clipanion' import { Command } from 'clipanion' +import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod' +import { ZodDefault, ZodEffects, ZodNullable, ZodOptional } from 'zod' +import type { RobotMetaInput } from '../alphalib/types/robots/_instructions-primitives.ts' import type { - GeneratedSchemaField, - ResolvedIntentCommandSpec, -} from './intentResolvedDefinitions.ts' -import { resolveIntentCommandSpecs } from './intentResolvedDefinitions.ts' + IntentDefinition, + IntentInputMode, + IntentOutputMode, + RobotIntentDefinition, + SemanticIntentDefinition, +} from './intentCommandSpecs.ts' +import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intentCommandSpecs.ts' +import type { IntentFieldKind, IntentFieldSpec } from './intentFields.ts' +import { inferIntentFieldKind } from './intentFields.ts' +import type { IntentInputPolicy } from './intentInputPolicy.ts' import { createIntentOption, GeneratedBundledFileIntentCommand, @@ -14,12 +23,543 @@ import { GeneratedWatchableFileIntentCommand, } from './intentRuntime.ts' +interface GeneratedSchemaField extends IntentFieldSpec { + description?: string + optionFlags: string + propertyName: string + required: boolean +} + +interface ResolvedIntentLocalFilesInput { + defaultSingleAssembly?: boolean + inputPolicy: IntentInputPolicy + kind: 'local-files' +} + +interface ResolvedIntentNoneInput { + kind: 'none' +} + +type ResolvedIntentInput = ResolvedIntentLocalFilesInput | ResolvedIntentNoneInput + +interface ResolvedIntentSchemaSpec { + schema: ZodObject +} + +interface ResolvedIntentSingleStepExecution { + fixedValues: Record + kind: 'single-step' + resultStepName: string +} + +interface ResolvedIntentDynamicExecution { + fields: GeneratedSchemaField[] + handler: 'image-describe' + kind: 'dynamic-step' + resultStepName: string +} + +interface ResolvedIntentTemplateExecution { + kind: 'template' + templateId: string +} + +type ResolvedIntentExecution = + | ResolvedIntentDynamicExecution + | ResolvedIntentSingleStepExecution + | ResolvedIntentTemplateExecution + +type ResolvedIntentRunnerKind = 'bundled' | 'no-input' | 'standard' | 'watchable' + +interface ResolvedIntentCommandSpec { + className: string + commandLabel: string + description: string + details: string + examples: Array<[string, string]> + execution: ResolvedIntentExecution + fieldSpecs: GeneratedSchemaField[] + input: ResolvedIntentInput + outputDescription: string + outputMode?: IntentOutputMode + paths: string[] + runnerKind: ResolvedIntentRunnerKind + schemaSpec?: ResolvedIntentSchemaSpec +} + +interface RobotIntentPresentation { + outputPath?: string + promptExample?: string + requiredExampleValues?: Partial> +} + +const hiddenFieldNames = new Set([ + 'ffmpeg_stack', + 'force_accept', + 'ignore_errors', + 'imagemagick_stack', + 'output_meta', + 'queue', + 'result', + 'robot', + 'stack', + 'use', +]) + +const robotIntentPresentationOverrides: Partial> = { + '/document/convert': { + outputPath: 'output.pdf', + requiredExampleValues: { format: 'pdf' }, + }, + '/file/compress': { + outputPath: 'archive.zip', + requiredExampleValues: { format: 'zip' }, + }, + '/image/generate': { + promptExample: 'A red bicycle in a studio', + requiredExampleValues: { model: 'flux-schnell' }, + }, + '/video/thumbs': { + requiredExampleValues: { format: 'jpg' }, + }, +} + type IntentBaseClass = | typeof GeneratedBundledFileIntentCommand | typeof GeneratedNoInputIntentCommand | typeof GeneratedStandardFileIntentCommand | typeof GeneratedWatchableFileIntentCommand +function toCamelCase(value: string): string { + return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()) +} + +function toKebabCase(value: string): string { + return value.replaceAll('_', '-') +} + +function toPascalCase(parts: string[]): string { + return parts + .flatMap((part) => part.split('-')) + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join('') +} + +function stripTrailingPunctuation(value: string): string { + return value.replace(/[.:]+$/, '').trim() +} + +function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { + let schema = input + let required = true + + while (true) { + if (schema instanceof ZodEffects) { + schema = schema._def.schema + continue + } + + if (schema instanceof ZodOptional) { + required = false + schema = schema.unwrap() + continue + } + + if (schema instanceof ZodDefault) { + required = false + schema = schema.removeDefault() + continue + } + + if (schema instanceof ZodNullable) { + required = false + schema = schema.unwrap() + continue + } + + return { required, schema } + } +} + +function getTypicalInputFile(meta: RobotMetaInput): string { + switch (meta.typical_file_type) { + case 'audio file': + return 'input.mp3' + case 'document': + return 'input.pdf' + case 'image': + return 'input.png' + case 'video': + return 'input.mp4' + default: + return 'input.file' + } +} + +function getDefaultOutputPath(paths: string[], outputMode: IntentOutputMode): string { + if (outputMode === 'directory') { + return 'output/' + } + + const [group] = paths + if (group === 'audio') return 'output.png' + if (group === 'document') return 'output.pdf' + if (group === 'image') return 'output.png' + if (group === 'text') return 'output.mp3' + return 'output.file' +} + +function getDefaultPromptExample(robot: string): string { + return robotIntentPresentationOverrides[robot]?.promptExample ?? 'Hello world' +} + +function getDefaultRequiredExampleValue( + definition: RobotIntentDefinition, + fieldSpec: GeneratedSchemaField, +): string | null { + const override = + robotIntentPresentationOverrides[definition.robot]?.requiredExampleValues?.[fieldSpec.name] + if (override != null) { + return override + } + + if (fieldSpec.name === 'aspect_ratio') return '1:1' + if (fieldSpec.name === 'prompt') return JSON.stringify(getDefaultPromptExample(definition.robot)) + if (fieldSpec.name === 'provider') return 'aws' + if (fieldSpec.name === 'target_language') return 'en-US' + if (fieldSpec.name === 'voice') return 'female-1' + + if (fieldSpec.kind === 'boolean') return 'true' + if (fieldSpec.kind === 'number') return '1' + + return 'value' +} + +function inferInputModeFromShape(shape: Record): IntentInputMode { + if ('prompt' in shape) { + return unwrapSchema(shape.prompt).required ? 'none' : 'local-files' + } + + return 'local-files' +} + +function inferIntentInput( + definition: RobotIntentDefinition, + shape: Record, +): ResolvedIntentInput { + const inputMode = definition.inputMode ?? inferInputModeFromShape(shape) + if (inputMode === 'none') { + return { kind: 'none' } + } + + const promptIsOptional = 'prompt' in shape && !unwrapSchema(shape.prompt).required + const inputPolicy = promptIsOptional + ? ({ + kind: 'optional', + field: 'prompt', + attachUseWhenInputsProvided: true, + } satisfies IntentInputPolicy) + : ({ kind: 'required' } satisfies IntentInputPolicy) + + if (definition.defaultSingleAssembly) { + return { + kind: 'local-files', + defaultSingleAssembly: true, + inputPolicy, + } + } + + return { + kind: 'local-files', + inputPolicy, + } +} + +function inferFixedValues( + definition: RobotIntentDefinition, + input: ResolvedIntentInput, + inputMode: IntentInputMode, +): Record { + if (definition.defaultSingleAssembly) { + return { + robot: definition.robot, + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + } + } + + if (inputMode === 'none') { + return { + robot: definition.robot, + result: true, + } + } + + if (input.kind === 'local-files' && input.inputPolicy.kind === 'required') { + return { + robot: definition.robot, + result: true, + use: ':original', + } + } + + return { + robot: definition.robot, + result: true, + } +} + +function collectSchemaFields( + schemaShape: Record, + fixedValues: Record, + input: ResolvedIntentInput, +): GeneratedSchemaField[] { + return Object.entries(schemaShape) + .filter(([key]) => !hiddenFieldNames.has(key) && !Object.hasOwn(fixedValues, key)) + .flatMap(([key, fieldSchema]) => { + const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) + + let kind: IntentFieldKind + try { + kind = inferIntentFieldKind(unwrappedSchema) + } catch { + return [] + } + + return [ + { + name: key, + propertyName: toCamelCase(key), + optionFlags: `--${toKebabCase(key)}`, + required: (input.kind === 'none' && key === 'prompt') || schemaRequired, + description: fieldSchema.description, + kind, + }, + ] + }) +} + +function inferExamples( + spec: ResolvedIntentCommandSpec, + definition?: RobotIntentDefinition, +): Array<[string, string]> { + if (definition == null) { + if (spec.execution.kind === 'dynamic-step') { + return spec.examples + } + + return [ + ['Run the command', `transloadit ${spec.paths.join(' ')} --input input.mp4 --out output/`], + ] + } + + const parts = ['transloadit', ...spec.paths] + const schemaShape = (definition.schema as ZodObject).shape as Record< + string, + ZodTypeAny + > + const inputMode = definition.inputMode ?? inferInputModeFromShape(schemaShape) + + if (inputMode === 'local-files') { + parts.push('--input', getTypicalInputFile(definition.meta)) + } + + if (inputMode === 'none') { + parts.push('--prompt', JSON.stringify(getDefaultPromptExample(definition.robot))) + } + + for (const fieldSpec of spec.fieldSpecs) { + if (!fieldSpec.required) continue + if (fieldSpec.name === 'prompt' && inputMode === 'none') continue + + const exampleValue = getDefaultRequiredExampleValue(definition, fieldSpec) + if (exampleValue == null) continue + parts.push(fieldSpec.optionFlags, exampleValue) + } + + const outputMode = spec.outputMode ?? 'file' + parts.push( + '--out', + robotIntentPresentationOverrides[definition.robot]?.outputPath ?? + getDefaultOutputPath(spec.paths, outputMode), + ) + + return [['Run the command', parts.join(' ')]] +} + +function resolveRobotIntent(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { + const paths = getIntentPaths(definition) + const className = `${toPascalCase(paths)}Command` + const commandLabel = paths.join(' ') + const schema = definition.schema as ZodObject + const schemaShape = schema.shape as Record + const inputMode = definition.inputMode ?? inferInputModeFromShape(schemaShape) + const input = inferIntentInput(definition, schemaShape) + const fixedValues = inferFixedValues(definition, input, inputMode) + const fieldSpecs = collectSchemaFields(schemaShape, fixedValues, input) + const outputMode = definition.outputMode ?? 'file' + + const spec: ResolvedIntentCommandSpec = { + className, + commandLabel, + description: stripTrailingPunctuation(definition.meta.title), + details: + inputMode === 'none' + ? `Runs \`${definition.robot}\` and writes the result to \`--out\`.` + : definition.defaultSingleAssembly === true + ? `Runs \`${definition.robot}\` for the provided inputs and writes the result to \`--out\`.` + : outputMode === 'directory' + ? `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` + : `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.`, + examples: [], + execution: { + kind: 'single-step', + fixedValues, + resultStepName: + getIntentResultStepName(definition) ?? + (() => { + throw new Error(`Could not infer result step name for "${definition.robot}"`) + })(), + }, + fieldSpecs, + input, + outputDescription: + outputMode === 'directory' + ? 'Write the results to this directory' + : inputMode === 'local-files' + ? 'Write the result to this path or directory' + : 'Write the result to this path', + outputMode, + paths, + runnerKind: + input.kind === 'none' ? 'no-input' : input.defaultSingleAssembly ? 'bundled' : 'standard', + schemaSpec: { schema }, + } + + return { + ...spec, + examples: inferExamples(spec, definition), + } +} + +function resolveImageDescribeIntent( + definition: SemanticIntentDefinition, +): ResolvedIntentCommandSpec { + const paths = getIntentPaths(definition) + return { + className: `${toPascalCase(paths)}Command`, + commandLabel: paths.join(' '), + description: 'Describe images as labels or publishable text fields', + details: + 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', + examples: [ + [ + 'Describe an image as labels', + 'transloadit image describe --input hero.jpg --out labels.json', + ], + [ + 'Generate WordPress-ready fields', + 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', + ], + [ + 'Request a custom field set', + 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', + ], + ], + execution: { + kind: 'dynamic-step', + handler: 'image-describe', + resultStepName: 'describe', + fields: [ + { + name: 'fields', + kind: 'string-array', + propertyName: 'fields', + optionFlags: '--fields', + description: + 'Describe output fields to generate, for example labels or altText,title,caption,description', + required: false, + }, + { + name: 'forProfile', + kind: 'string', + propertyName: 'forProfile', + optionFlags: '--for', + description: 'Use a named output profile, currently: wordpress', + required: false, + }, + { + name: 'model', + kind: 'string', + propertyName: 'model', + optionFlags: '--model', + description: + 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', + required: false, + }, + ], + }, + fieldSpecs: [], + input: { + kind: 'local-files', + inputPolicy: { kind: 'required' }, + }, + outputDescription: 'Write the JSON result to this path or directory', + paths, + runnerKind: 'watchable', + } +} + +function resolveTemplateIntent( + definition: IntentDefinition & { kind: 'template' }, +): ResolvedIntentCommandSpec { + const outputMode = definition.outputMode ?? 'file' + const paths = getIntentPaths(definition) + const spec: ResolvedIntentCommandSpec = { + className: `${toPascalCase(paths)}Command`, + commandLabel: paths.join(' '), + description: `Run ${stripTrailingPunctuation(definition.templateId)}`, + details: `Runs the \`${definition.templateId}\` template and writes the outputs to \`--out\`.`, + examples: [], + execution: { + kind: 'template', + templateId: definition.templateId, + }, + fieldSpecs: [], + input: { + kind: 'local-files', + inputPolicy: { kind: 'required' }, + }, + outputDescription: + outputMode === 'directory' + ? 'Write the results to this directory' + : 'Write the result to this path or directory', + outputMode, + paths, + runnerKind: 'standard', + } + + return { + ...spec, + examples: inferExamples(spec), + } +} + +function resolveIntent(definition: IntentDefinition): ResolvedIntentCommandSpec { + if (definition.kind === 'robot') { + return resolveRobotIntent(definition) + } + + if (definition.kind === 'semantic') { + return resolveImageDescribeIntent(definition) + } + + return resolveTemplateIntent(definition) +} + function getOptionFields(spec: ResolvedIntentCommandSpec): readonly GeneratedSchemaField[] { if (spec.execution.kind === 'dynamic-step') { return spec.execution.fields @@ -113,4 +653,4 @@ function createIntentCommandClass(spec: ResolvedIntentCommandSpec): CommandClass return RuntimeIntentCommand as unknown as CommandClass } -export const intentCommands = resolveIntentCommandSpecs().map(createIntentCommandClass) +export const intentCommands = intentCatalog.map(resolveIntent).map(createIntentCommandClass) diff --git a/packages/node/src/cli/intentResolvedDefinitions.ts b/packages/node/src/cli/intentResolvedDefinitions.ts deleted file mode 100644 index 96c1f8b5..00000000 --- a/packages/node/src/cli/intentResolvedDefinitions.ts +++ /dev/null @@ -1,602 +0,0 @@ -import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod' -import { ZodDefault, ZodEffects, ZodNullable, ZodOptional } from 'zod' - -import type { RobotMetaInput } from '../alphalib/types/robots/_instructions-primitives.ts' -import type { - IntentDefinition, - IntentInputMode, - IntentOutputMode, - RobotIntentDefinition, - SemanticIntentDefinition, -} from './intentCommandSpecs.ts' -import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intentCommandSpecs.ts' -import type { IntentFieldKind, IntentFieldSpec } from './intentFields.ts' -import { inferIntentFieldKind } from './intentFields.ts' -import type { IntentInputPolicy } from './intentInputPolicy.ts' - -export interface GeneratedSchemaField extends IntentFieldSpec { - description?: string - optionFlags: string - propertyName: string - required: boolean -} - -export interface ResolvedIntentLocalFilesInput { - defaultSingleAssembly?: boolean - inputPolicy: IntentInputPolicy - kind: 'local-files' -} - -export interface ResolvedIntentNoneInput { - kind: 'none' -} - -export type ResolvedIntentInput = ResolvedIntentLocalFilesInput | ResolvedIntentNoneInput - -export interface ResolvedIntentSchemaSpec { - importName: string - importPath: string - schema: ZodObject -} - -export interface ResolvedIntentSingleStepExecution { - fixedValues: Record - kind: 'single-step' - resultStepName: string -} - -export interface ResolvedIntentDynamicExecution { - fields: GeneratedSchemaField[] - handler: 'image-describe' - kind: 'dynamic-step' - resultStepName: string -} - -export interface ResolvedIntentTemplateExecution { - kind: 'template' - templateId: string -} - -export type ResolvedIntentExecution = - | ResolvedIntentDynamicExecution - | ResolvedIntentSingleStepExecution - | ResolvedIntentTemplateExecution - -export type ResolvedIntentRunnerKind = 'bundled' | 'no-input' | 'standard' | 'watchable' - -export interface ResolvedIntentCommandSpec { - className: string - commandLabel: string - description: string - details: string - examples: Array<[string, string]> - execution: ResolvedIntentExecution - fieldSpecs: GeneratedSchemaField[] - input: ResolvedIntentInput - outputDescription: string - outputMode?: IntentOutputMode - paths: string[] - runnerKind: ResolvedIntentRunnerKind - schemaSpec?: ResolvedIntentSchemaSpec -} - -interface RobotIntentPresentation { - outputPath?: string - promptExample?: string - requiredExampleValues?: Partial> -} - -interface RobotIntentAnalysis { - className: string - commandLabel: string - definition: RobotIntentDefinition - details: string - description: string - execution: ResolvedIntentSingleStepExecution - fieldSpecs: GeneratedSchemaField[] - input: ResolvedIntentInput - inputMode: IntentInputMode - outputDescription: string - outputMode: IntentOutputMode - paths: string[] - presentation: RobotIntentPresentation - schemaShape: Record - schemaSpec: ResolvedIntentSchemaSpec -} - -const hiddenFieldNames = new Set([ - 'ffmpeg_stack', - 'force_accept', - 'ignore_errors', - 'imagemagick_stack', - 'output_meta', - 'queue', - 'result', - 'robot', - 'stack', - 'use', -]) - -const robotIntentPresentationOverrides: Partial> = { - '/document/convert': { - outputPath: 'output.pdf', - requiredExampleValues: { format: 'pdf' }, - }, - '/file/compress': { - outputPath: 'archive.zip', - requiredExampleValues: { format: 'zip' }, - }, - '/image/generate': { - promptExample: 'A red bicycle in a studio', - requiredExampleValues: { model: 'flux-schnell' }, - }, - '/video/thumbs': { - requiredExampleValues: { format: 'jpg' }, - }, -} - -function toCamelCase(value: string): string { - return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()) -} - -function toKebabCase(value: string): string { - return value.replaceAll('_', '-') -} - -function toPascalCase(parts: string[]): string { - return parts - .flatMap((part) => part.split('-')) - .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) - .join('') -} - -function getSchemaImportName(robot: string): string { - return `robot${toPascalCase(robot.split('/').filter(Boolean))}InstructionsSchema` -} - -function getSchemaImportPath(robot: string): string { - return `../../alphalib/types/robots/${robot.split('/').filter(Boolean).join('-')}.ts` -} - -function stripTrailingPunctuation(value: string): string { - return value.replace(/[.:]+$/, '').trim() -} - -function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { - let schema = input - let required = true - - while (true) { - if (schema instanceof ZodEffects) { - schema = schema._def.schema - continue - } - - if (schema instanceof ZodOptional) { - required = false - schema = schema.unwrap() - continue - } - - if (schema instanceof ZodDefault) { - required = false - schema = schema.removeDefault() - continue - } - - if (schema instanceof ZodNullable) { - required = false - schema = schema.unwrap() - continue - } - - return { required, schema } - } -} - -function getTypicalInputFile(meta: RobotMetaInput): string { - switch (meta.typical_file_type) { - case 'audio file': - return 'input.mp3' - case 'document': - return 'input.pdf' - case 'image': - return 'input.png' - case 'video': - return 'input.mp4' - default: - return 'input.file' - } -} - -function getDefaultOutputPath(paths: string[], outputMode: IntentOutputMode): string { - if (outputMode === 'directory') { - return 'output/' - } - - const [group] = paths - if (group === 'audio') return 'output.png' - if (group === 'document') return 'output.pdf' - if (group === 'image') return 'output.png' - if (group === 'text') return 'output.mp3' - return 'output.file' -} - -function getDefaultPromptExample(robot: string): string { - return robotIntentPresentationOverrides[robot]?.promptExample ?? 'Hello world' -} - -function getDefaultRequiredExampleValue( - definition: RobotIntentDefinition, - fieldSpec: GeneratedSchemaField, -): string | null { - const override = - robotIntentPresentationOverrides[definition.robot]?.requiredExampleValues?.[fieldSpec.name] - if (override != null) { - return override - } - - if (fieldSpec.name === 'aspect_ratio') return '1:1' - if (fieldSpec.name === 'prompt') return JSON.stringify(getDefaultPromptExample(definition.robot)) - if (fieldSpec.name === 'provider') return 'aws' - if (fieldSpec.name === 'target_language') return 'en-US' - if (fieldSpec.name === 'voice') return 'female-1' - - if (fieldSpec.kind === 'boolean') return 'true' - if (fieldSpec.kind === 'number') return '1' - - return 'value' -} - -function inferInputModeFromShape(shape: Record): IntentInputMode { - if ('prompt' in shape) { - return unwrapSchema(shape.prompt).required ? 'none' : 'local-files' - } - - return 'local-files' -} - -function inferInputSpecFromAnalysis({ - defaultSingleAssembly, - inputMode, - inputPolicy, -}: { - defaultSingleAssembly?: boolean - inputMode: IntentInputMode - inputPolicy: IntentInputPolicy -}): ResolvedIntentInput { - if (inputMode === 'none') { - return { kind: 'none' } - } - - if (defaultSingleAssembly) { - return { - kind: 'local-files', - defaultSingleAssembly: true, - inputPolicy, - } - } - - return { - kind: 'local-files', - inputPolicy, - } -} - -function inferFixedValuesFromAnalysis({ - defaultSingleAssembly, - inputMode, - inputPolicy, - robot, -}: { - defaultSingleAssembly?: boolean - inputMode: IntentInputMode - inputPolicy: IntentInputPolicy - robot: string -}): Record { - if (defaultSingleAssembly) { - return { - robot, - result: true, - use: { - steps: [':original'], - bundle_steps: true, - }, - } - } - - if (inputMode === 'none') { - return { - robot, - result: true, - } - } - - if (inputPolicy.kind === 'required') { - return { - robot, - result: true, - use: ':original', - } - } - - return { - robot, - result: true, - } -} - -function collectSchemaFields( - schemaShape: Record, - fixedValues: Record, - input: ResolvedIntentInput, -): GeneratedSchemaField[] { - return Object.entries(schemaShape) - .filter(([key]) => !hiddenFieldNames.has(key) && !Object.hasOwn(fixedValues, key)) - .flatMap(([key, fieldSchema]) => { - const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) - - let kind: IntentFieldKind - try { - kind = inferIntentFieldKind(unwrappedSchema) - } catch { - return [] - } - - return [ - { - name: key, - propertyName: toCamelCase(key), - optionFlags: `--${toKebabCase(key)}`, - required: (input.kind === 'none' && key === 'prompt') || schemaRequired, - description: fieldSchema.description, - kind, - }, - ] - }) -} - -function analyzeRobotIntent(definition: RobotIntentDefinition): RobotIntentAnalysis { - const paths = getIntentPaths(definition) - const commandLabel = paths.join(' ') - const className = `${toPascalCase(paths)}Command` - const outputMode = definition.outputMode ?? 'file' - const schemaSpec = { - importName: getSchemaImportName(definition.robot), - importPath: getSchemaImportPath(definition.robot), - schema: definition.schema as ZodObject, - } satisfies ResolvedIntentSchemaSpec - const schemaShape = schemaSpec.schema.shape as Record - const inputMode = definition.inputMode ?? inferInputModeFromShape(schemaShape) - const promptIsOptional = 'prompt' in schemaShape && !unwrapSchema(schemaShape.prompt).required - const inputPolicy = promptIsOptional - ? ({ - kind: 'optional', - field: 'prompt', - attachUseWhenInputsProvided: true, - } satisfies IntentInputPolicy) - : ({ kind: 'required' } satisfies IntentInputPolicy) - const input = inferInputSpecFromAnalysis({ - defaultSingleAssembly: definition.defaultSingleAssembly, - inputMode, - inputPolicy, - }) - const execution = { - kind: 'single-step', - resultStepName: - getIntentResultStepName(definition) ?? - (() => { - throw new Error(`Could not infer result step name for "${definition.robot}"`) - })(), - fixedValues: inferFixedValuesFromAnalysis({ - defaultSingleAssembly: definition.defaultSingleAssembly, - inputMode, - inputPolicy, - robot: definition.robot, - }), - } satisfies ResolvedIntentSingleStepExecution - const fieldSpecs = collectSchemaFields(schemaShape, execution.fixedValues, input) - const description = stripTrailingPunctuation(definition.meta.title) - const details = - inputMode === 'none' - ? `Runs \`${definition.robot}\` and writes the result to \`--out\`.` - : definition.defaultSingleAssembly === true - ? `Runs \`${definition.robot}\` for the provided inputs and writes the result to \`--out\`.` - : outputMode === 'directory' - ? `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` - : `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.` - - return { - className, - commandLabel, - definition, - details, - description, - execution, - fieldSpecs, - input, - inputMode, - outputDescription: - outputMode === 'directory' - ? 'Write the results to this directory' - : inputMode === 'local-files' - ? 'Write the result to this path or directory' - : 'Write the result to this path', - outputMode, - paths, - presentation: robotIntentPresentationOverrides[definition.robot] ?? {}, - schemaShape, - schemaSpec, - } -} - -function inferExamples(analysis: RobotIntentAnalysis): Array<[string, string]> { - const parts = ['transloadit', ...analysis.paths] - - if (analysis.inputMode === 'local-files') { - parts.push('--input', getTypicalInputFile(analysis.definition.meta)) - } - - if (analysis.inputMode === 'none') { - parts.push('--prompt', JSON.stringify(getDefaultPromptExample(analysis.definition.robot))) - } - - for (const fieldSpec of analysis.fieldSpecs) { - if (!fieldSpec.required) continue - if (fieldSpec.name === 'prompt' && analysis.inputMode === 'none') continue - - const exampleValue = getDefaultRequiredExampleValue(analysis.definition, fieldSpec) - if (exampleValue == null) continue - parts.push(fieldSpec.optionFlags, exampleValue) - } - - parts.push( - '--out', - analysis.presentation.outputPath ?? getDefaultOutputPath(analysis.paths, analysis.outputMode), - ) - - return [['Run the command', parts.join(' ')]] -} - -function resolveRobotIntentSpec(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { - const analysis = analyzeRobotIntent(definition) - - return { - className: analysis.className, - commandLabel: analysis.commandLabel, - description: analysis.description, - details: analysis.details, - examples: inferExamples(analysis), - execution: analysis.execution, - fieldSpecs: analysis.fieldSpecs, - input: analysis.input, - outputDescription: analysis.outputDescription, - outputMode: analysis.outputMode, - paths: analysis.paths, - runnerKind: - analysis.input.kind === 'none' - ? 'no-input' - : analysis.input.defaultSingleAssembly - ? 'bundled' - : 'standard', - schemaSpec: analysis.schemaSpec, - } -} - -function resolveImageDescribeIntentSpec( - definition: SemanticIntentDefinition, -): ResolvedIntentCommandSpec { - const paths = getIntentPaths(definition) - - return { - className: `${toPascalCase(paths)}Command`, - commandLabel: paths.join(' '), - description: 'Describe images as labels or publishable text fields', - details: - 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', - examples: [ - [ - 'Describe an image as labels', - 'transloadit image describe --input hero.jpg --out labels.json', - ], - [ - 'Generate WordPress-ready fields', - 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', - ], - [ - 'Request a custom field set', - 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', - ], - ], - execution: { - kind: 'dynamic-step', - handler: 'image-describe', - resultStepName: 'describe', - fields: [ - { - name: 'fields', - kind: 'string-array', - propertyName: 'fields', - optionFlags: '--fields', - description: - 'Describe output fields to generate, for example labels or altText,title,caption,description', - required: false, - }, - { - name: 'forProfile', - kind: 'string', - propertyName: 'forProfile', - optionFlags: '--for', - description: 'Use a named output profile, currently: wordpress', - required: false, - }, - { - name: 'model', - kind: 'string', - propertyName: 'model', - optionFlags: '--model', - description: - 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', - required: false, - }, - ], - }, - fieldSpecs: [], - input: inferInputSpecFromAnalysis({ - inputMode: 'local-files', - inputPolicy: { kind: 'required' }, - }), - outputDescription: 'Write the JSON result to this path or directory', - paths, - runnerKind: 'watchable', - } -} - -function resolveTemplateIntentSpec( - definition: IntentDefinition & { kind: 'template' }, -): ResolvedIntentCommandSpec { - const outputMode = definition.outputMode ?? 'file' - const paths = getIntentPaths(definition) - - return { - className: `${toPascalCase(paths)}Command`, - commandLabel: paths.join(' '), - description: `Run ${stripTrailingPunctuation(definition.templateId)}`, - details: `Runs the \`${definition.templateId}\` template and writes the outputs to \`--out\`.`, - examples: [ - ['Run the command', `transloadit ${paths.join(' ')} --input input.mp4 --out output/`], - ], - execution: { - kind: 'template', - templateId: definition.templateId, - }, - fieldSpecs: [], - input: inferInputSpecFromAnalysis({ - inputMode: 'local-files', - inputPolicy: { kind: 'required' }, - }), - outputDescription: - outputMode === 'directory' - ? 'Write the results to this directory' - : 'Write the result to this path or directory', - outputMode, - paths, - runnerKind: 'standard', - } -} - -export function resolveIntentCommandSpec(definition: IntentDefinition): ResolvedIntentCommandSpec { - if (definition.kind === 'robot') { - return resolveRobotIntentSpec(definition) - } - - if (definition.kind === 'semantic') { - return resolveImageDescribeIntentSpec(definition) - } - - return resolveTemplateIntentSpec(definition) -} - -export function resolveIntentCommandSpecs(): ResolvedIntentCommandSpec[] { - return intentCatalog.map(resolveIntentCommandSpec) -} From 8b55d17a02b0fa6d02891f6889d95bb8be42c046 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 15:42:26 +0200 Subject: [PATCH 33/44] refactor(node): trim intent duplication --- packages/node/scripts/test-intents-e2e.sh | 2 +- packages/node/src/cli/intentCommands.ts | 95 +++++++++++-------- .../cli => test/support}/intentSmokeCases.ts | 6 +- packages/node/test/unit/cli/intents.test.ts | 2 +- 4 files changed, 60 insertions(+), 45 deletions(-) rename packages/node/{src/cli => test/support}/intentSmokeCases.ts (96%) diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index 3dbc4328..88920978 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -218,7 +218,7 @@ while IFS=$'\t' read -r name path_string args_string output_rel verifier; do >>"$RESULTS_TSV" done < <( node --input-type=module <<'NODE' -import { intentSmokeCases } from './packages/node/src/cli/intentSmokeCases.ts' +import { intentSmokeCases } from './packages/node/test/support/intentSmokeCases.ts' for (const smokeCase of intentSmokeCases) { console.log([ diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index dcac6e2a..2e5babe8 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -87,12 +87,6 @@ interface ResolvedIntentCommandSpec { schemaSpec?: ResolvedIntentSchemaSpec } -interface RobotIntentPresentation { - outputPath?: string - promptExample?: string - requiredExampleValues?: Partial> -} - const hiddenFieldNames = new Set([ 'ffmpeg_stack', 'force_accept', @@ -106,24 +100,6 @@ const hiddenFieldNames = new Set([ 'use', ]) -const robotIntentPresentationOverrides: Partial> = { - '/document/convert': { - outputPath: 'output.pdf', - requiredExampleValues: { format: 'pdf' }, - }, - '/file/compress': { - outputPath: 'archive.zip', - requiredExampleValues: { format: 'zip' }, - }, - '/image/generate': { - promptExample: 'A red bicycle in a studio', - requiredExampleValues: { model: 'flux-schnell' }, - }, - '/video/thumbs': { - requiredExampleValues: { format: 'jpg' }, - }, -} - type IntentBaseClass = | typeof GeneratedBundledFileIntentCommand | typeof GeneratedNoInputIntentCommand @@ -209,22 +185,27 @@ function getDefaultOutputPath(paths: string[], outputMode: IntentOutputMode): st return 'output.file' } -function getDefaultPromptExample(robot: string): string { - return robotIntentPresentationOverrides[robot]?.promptExample ?? 'Hello world' +function isIntentPath(paths: string[], expectedGroup: string, expectedAction: string): boolean { + return paths[0] === expectedGroup && paths[1] === expectedAction } -function getDefaultRequiredExampleValue( - definition: RobotIntentDefinition, - fieldSpec: GeneratedSchemaField, -): string | null { - const override = - robotIntentPresentationOverrides[definition.robot]?.requiredExampleValues?.[fieldSpec.name] - if (override != null) { - return override +function inferPromptExample(paths: string[]): string { + if (isIntentPath(paths, 'image', 'generate')) { + return 'A red bicycle in a studio' } + return 'Hello world' +} + +function inferRequiredExampleValue( + paths: string[], + fieldSpec: GeneratedSchemaField, +): string | null { if (fieldSpec.name === 'aspect_ratio') return '1:1' - if (fieldSpec.name === 'prompt') return JSON.stringify(getDefaultPromptExample(definition.robot)) + if (fieldSpec.name === 'format' && isIntentPath(paths, 'document', 'convert')) return 'pdf' + if (fieldSpec.name === 'format' && isIntentPath(paths, 'file', 'compress')) return 'zip' + if (fieldSpec.name === 'format' && isIntentPath(paths, 'video', 'thumbs')) return 'jpg' + if (fieldSpec.name === 'prompt') return JSON.stringify(inferPromptExample(paths)) if (fieldSpec.name === 'provider') return 'aws' if (fieldSpec.name === 'target_language') return 'en-US' if (fieldSpec.name === 'voice') return 'female-1' @@ -235,6 +216,40 @@ function getDefaultRequiredExampleValue( return 'value' } +function inferOutputPath( + paths: string[], + outputMode: IntentOutputMode, + fieldSpecs: readonly GeneratedSchemaField[], +): string { + if (outputMode === 'directory') { + return 'output/' + } + + if (isIntentPath(paths, 'file', 'compress')) { + const formatExample = fieldSpecs + .map((fieldSpec) => + fieldSpec.name === 'format' ? inferRequiredExampleValue(paths, fieldSpec) : null, + ) + .find((value) => value != null) + + return `archive.${formatExample ?? 'zip'}` + } + + const formatExample = fieldSpecs + .map((fieldSpec) => + fieldSpec.required && fieldSpec.name === 'format' + ? inferRequiredExampleValue(paths, fieldSpec) + : null, + ) + .find((value) => value != null) + + if (formatExample != null && /^[-\w]+$/.test(formatExample)) { + return `output.${formatExample}` + } + + return getDefaultOutputPath(paths, outputMode) +} + function inferInputModeFromShape(shape: Record): IntentInputMode { if ('prompt' in shape) { return unwrapSchema(shape.prompt).required ? 'none' : 'local-files' @@ -368,24 +383,20 @@ function inferExamples( } if (inputMode === 'none') { - parts.push('--prompt', JSON.stringify(getDefaultPromptExample(definition.robot))) + parts.push('--prompt', JSON.stringify(inferPromptExample(spec.paths))) } for (const fieldSpec of spec.fieldSpecs) { if (!fieldSpec.required) continue if (fieldSpec.name === 'prompt' && inputMode === 'none') continue - const exampleValue = getDefaultRequiredExampleValue(definition, fieldSpec) + const exampleValue = inferRequiredExampleValue(spec.paths, fieldSpec) if (exampleValue == null) continue parts.push(fieldSpec.optionFlags, exampleValue) } const outputMode = spec.outputMode ?? 'file' - parts.push( - '--out', - robotIntentPresentationOverrides[definition.robot]?.outputPath ?? - getDefaultOutputPath(spec.paths, outputMode), - ) + parts.push('--out', inferOutputPath(spec.paths, outputMode, spec.fieldSpecs)) return [['Run the command', parts.join(' ')]] } diff --git a/packages/node/src/cli/intentSmokeCases.ts b/packages/node/test/support/intentSmokeCases.ts similarity index 96% rename from packages/node/src/cli/intentSmokeCases.ts rename to packages/node/test/support/intentSmokeCases.ts index a3097d55..c66b787c 100644 --- a/packages/node/src/cli/intentSmokeCases.ts +++ b/packages/node/test/support/intentSmokeCases.ts @@ -1,4 +1,8 @@ -import { getIntentCatalogKey, getIntentPaths, intentCatalog } from './intentCommandSpecs.ts' +import { + getIntentCatalogKey, + getIntentPaths, + intentCatalog, +} from '../../src/cli/intentCommandSpecs.ts' export interface IntentSmokeCase { args: string[] diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 2e7c0b63..72db8164 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -14,9 +14,9 @@ import { import { intentCommands } from '../../../src/cli/intentCommands.ts' import { coerceIntentFieldValue } from '../../../src/cli/intentFields.ts' import { prepareIntentInputs } from '../../../src/cli/intentRuntime.ts' -import { intentSmokeCases } from '../../../src/cli/intentSmokeCases.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' +import { intentSmokeCases } from '../../support/intentSmokeCases.ts' const noopWrite = () => true const tempDirs: string[] = [] From 809b229177cac0bb96c1c43ba2ff3b245ddc3f08 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 17:08:29 +0200 Subject: [PATCH 34/44] refactor(node): tighten intent definition flow --- packages/node/src/cli/commands/assemblies.ts | 13 +- packages/node/src/cli/intentCommands.ts | 289 ++++++------------ packages/node/src/cli/intentRuntime.ts | 232 +------------- .../src/cli/semanticIntents/imageDescribe.ts | 276 +++++++++++++++++ 4 files changed, 388 insertions(+), 422 deletions(-) create mode 100644 packages/node/src/cli/semanticIntents/imageDescribe.ts diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 57092d7a..c46ef36a 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1501,12 +1501,13 @@ export async function create( const inputPaths: string[] = [] for (const inPath of collectedPaths) { const basename = path.basename(inPath) - let key = basename - let counter = 1 - while (key in uploads) { - key = `${path.parse(basename).name}_${counter}${path.parse(basename).ext}` - counter++ - } + const key = await ensureUniqueCounterValue({ + initialValue: basename, + isTaken: (candidate) => candidate in uploads, + nextValue: (counter) => + `${path.parse(basename).name}_${counter}${path.parse(basename).ext}`, + reserve: () => {}, + }) uploads[key] = createInputUploadStream(inPath) inputPaths.push(inPath) } diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index 2e5babe8..891f7eec 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -15,13 +15,24 @@ import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intent import type { IntentFieldKind, IntentFieldSpec } from './intentFields.ts' import { inferIntentFieldKind } from './intentFields.ts' import type { IntentInputPolicy } from './intentInputPolicy.ts' +import type { + IntentCommandDefinition, + IntentFileCommandDefinition, + IntentNoInputCommandDefinition, + IntentSingleStepExecutionDefinition, +} from './intentRuntime.ts' import { createIntentOption, GeneratedBundledFileIntentCommand, GeneratedNoInputIntentCommand, GeneratedStandardFileIntentCommand, GeneratedWatchableFileIntentCommand, + getIntentOptionDefinitions, } from './intentRuntime.ts' +import { + imageDescribeCommandPresentation, + imageDescribeExecutionDefinition, +} from './semanticIntents/imageDescribe.ts' interface GeneratedSchemaField extends IntentFieldSpec { description?: string @@ -42,49 +53,14 @@ interface ResolvedIntentNoneInput { type ResolvedIntentInput = ResolvedIntentLocalFilesInput | ResolvedIntentNoneInput -interface ResolvedIntentSchemaSpec { - schema: ZodObject -} - -interface ResolvedIntentSingleStepExecution { - fixedValues: Record - kind: 'single-step' - resultStepName: string -} - -interface ResolvedIntentDynamicExecution { - fields: GeneratedSchemaField[] - handler: 'image-describe' - kind: 'dynamic-step' - resultStepName: string -} - -interface ResolvedIntentTemplateExecution { - kind: 'template' - templateId: string -} +type IntentBaseClass = + | typeof GeneratedBundledFileIntentCommand + | typeof GeneratedNoInputIntentCommand + | typeof GeneratedStandardFileIntentCommand + | typeof GeneratedWatchableFileIntentCommand -type ResolvedIntentExecution = - | ResolvedIntentDynamicExecution - | ResolvedIntentSingleStepExecution - | ResolvedIntentTemplateExecution - -type ResolvedIntentRunnerKind = 'bundled' | 'no-input' | 'standard' | 'watchable' - -interface ResolvedIntentCommandSpec { - className: string - commandLabel: string - description: string - details: string - examples: Array<[string, string]> - execution: ResolvedIntentExecution - fieldSpecs: GeneratedSchemaField[] - input: ResolvedIntentInput - outputDescription: string - outputMode?: IntentOutputMode - paths: string[] - runnerKind: ResolvedIntentRunnerKind - schemaSpec?: ResolvedIntentSchemaSpec +type BuiltIntentCommandDefinition = IntentCommandDefinition & { + intentDefinition: IntentFileCommandDefinition | IntentNoInputCommandDefinition } const hiddenFieldNames = new Set([ @@ -100,12 +76,6 @@ const hiddenFieldNames = new Set([ 'use', ]) -type IntentBaseClass = - | typeof GeneratedBundledFileIntentCommand - | typeof GeneratedNoInputIntentCommand - | typeof GeneratedStandardFileIntentCommand - | typeof GeneratedWatchableFileIntentCommand - function toCamelCase(value: string): string { return value.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()) } @@ -358,11 +328,11 @@ function collectSchemaFields( } function inferExamples( - spec: ResolvedIntentCommandSpec, + spec: BuiltIntentCommandDefinition, definition?: RobotIntentDefinition, ): Array<[string, string]> { if (definition == null) { - if (spec.execution.kind === 'dynamic-step') { + if (spec.intentDefinition.execution.kind === 'dynamic-step') { return spec.examples } @@ -386,7 +356,12 @@ function inferExamples( parts.push('--prompt', JSON.stringify(inferPromptExample(spec.paths))) } - for (const fieldSpec of spec.fieldSpecs) { + const fieldSpecs = + spec.intentDefinition.execution.kind === 'single-step' + ? (spec.intentDefinition.execution.fields as readonly GeneratedSchemaField[]) + : [] + + for (const fieldSpec of fieldSpecs) { if (!fieldSpec.required) continue if (fieldSpec.name === 'prompt' && inputMode === 'none') continue @@ -395,13 +370,13 @@ function inferExamples( parts.push(fieldSpec.optionFlags, exampleValue) } - const outputMode = spec.outputMode ?? 'file' - parts.push('--out', inferOutputPath(spec.paths, outputMode, spec.fieldSpecs)) + const outputMode = spec.intentDefinition.outputMode ?? 'file' + parts.push('--out', inferOutputPath(spec.paths, outputMode, fieldSpecs)) return [['Run the command', parts.join(' ')]] } -function resolveRobotIntent(definition: RobotIntentDefinition): ResolvedIntentCommandSpec { +function resolveRobotIntent(definition: RobotIntentDefinition): BuiltIntentCommandDefinition { const paths = getIntentPaths(definition) const className = `${toPascalCase(paths)}Command` const commandLabel = paths.join(' ') @@ -412,10 +387,20 @@ function resolveRobotIntent(definition: RobotIntentDefinition): ResolvedIntentCo const fixedValues = inferFixedValues(definition, input, inputMode) const fieldSpecs = collectSchemaFields(schemaShape, fixedValues, input) const outputMode = definition.outputMode ?? 'file' + const execution: IntentSingleStepExecutionDefinition = { + kind: 'single-step', + schema, + fields: fieldSpecs, + fixedValues, + resultStepName: + getIntentResultStepName(definition) ?? + (() => { + throw new Error(`Could not infer result step name for "${definition.robot}"`) + })(), + } - const spec: ResolvedIntentCommandSpec = { + const spec: BuiltIntentCommandDefinition = { className, - commandLabel, description: stripTrailingPunctuation(definition.meta.title), details: inputMode === 'none' @@ -426,28 +411,26 @@ function resolveRobotIntent(definition: RobotIntentDefinition): ResolvedIntentCo ? `Runs \`${definition.robot}\` on each input file and writes the results to \`--out\`.` : `Runs \`${definition.robot}\` on each input file and writes the result to \`--out\`.`, examples: [], - execution: { - kind: 'single-step', - fixedValues, - resultStepName: - getIntentResultStepName(definition) ?? - (() => { - throw new Error(`Could not infer result step name for "${definition.robot}"`) - })(), - }, - fieldSpecs, - input, - outputDescription: - outputMode === 'directory' - ? 'Write the results to this directory' - : inputMode === 'local-files' - ? 'Write the result to this path or directory' - : 'Write the result to this path', - outputMode, paths, runnerKind: input.kind === 'none' ? 'no-input' : input.defaultSingleAssembly ? 'bundled' : 'standard', - schemaSpec: { schema }, + intentDefinition: + input.kind === 'none' + ? { + execution, + outputDescription: 'Write the result to this path', + outputMode, + } + : { + commandLabel, + execution, + inputPolicy: input.inputPolicy, + outputDescription: + outputMode === 'directory' + ? 'Write the results to this directory' + : 'Write the result to this path or directory', + outputMode, + }, } return { @@ -458,99 +441,50 @@ function resolveRobotIntent(definition: RobotIntentDefinition): ResolvedIntentCo function resolveImageDescribeIntent( definition: SemanticIntentDefinition, -): ResolvedIntentCommandSpec { +): BuiltIntentCommandDefinition { const paths = getIntentPaths(definition) + return { className: `${toPascalCase(paths)}Command`, - commandLabel: paths.join(' '), - description: 'Describe images as labels or publishable text fields', - details: - 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', - examples: [ - [ - 'Describe an image as labels', - 'transloadit image describe --input hero.jpg --out labels.json', - ], - [ - 'Generate WordPress-ready fields', - 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', - ], - [ - 'Request a custom field set', - 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', - ], - ], - execution: { - kind: 'dynamic-step', - handler: 'image-describe', - resultStepName: 'describe', - fields: [ - { - name: 'fields', - kind: 'string-array', - propertyName: 'fields', - optionFlags: '--fields', - description: - 'Describe output fields to generate, for example labels or altText,title,caption,description', - required: false, - }, - { - name: 'forProfile', - kind: 'string', - propertyName: 'forProfile', - optionFlags: '--for', - description: 'Use a named output profile, currently: wordpress', - required: false, - }, - { - name: 'model', - kind: 'string', - propertyName: 'model', - optionFlags: '--model', - description: - 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', - required: false, - }, - ], - }, - fieldSpecs: [], - input: { - kind: 'local-files', - inputPolicy: { kind: 'required' }, - }, - outputDescription: 'Write the JSON result to this path or directory', + description: imageDescribeCommandPresentation.description, + details: imageDescribeCommandPresentation.details, + examples: [...imageDescribeCommandPresentation.examples], paths, runnerKind: 'watchable', + intentDefinition: { + commandLabel: paths.join(' '), + execution: imageDescribeExecutionDefinition, + inputPolicy: { kind: 'required' }, + outputDescription: 'Write the JSON result to this path or directory', + }, } } function resolveTemplateIntent( definition: IntentDefinition & { kind: 'template' }, -): ResolvedIntentCommandSpec { +): BuiltIntentCommandDefinition { const outputMode = definition.outputMode ?? 'file' const paths = getIntentPaths(definition) - const spec: ResolvedIntentCommandSpec = { + const spec: BuiltIntentCommandDefinition = { className: `${toPascalCase(paths)}Command`, - commandLabel: paths.join(' '), description: `Run ${stripTrailingPunctuation(definition.templateId)}`, details: `Runs the \`${definition.templateId}\` template and writes the outputs to \`--out\`.`, examples: [], - execution: { - kind: 'template', - templateId: definition.templateId, - }, - fieldSpecs: [], - input: { - kind: 'local-files', - inputPolicy: { kind: 'required' }, - }, - outputDescription: - outputMode === 'directory' - ? 'Write the results to this directory' - : 'Write the result to this path or directory', - outputMode, paths, runnerKind: 'standard', + intentDefinition: { + commandLabel: paths.join(' '), + execution: { + kind: 'template', + templateId: definition.templateId, + }, + inputPolicy: { kind: 'required' }, + outputDescription: + outputMode === 'directory' + ? 'Write the results to this directory' + : 'Write the result to this path or directory', + outputMode, + }, } return { @@ -559,7 +493,7 @@ function resolveTemplateIntent( } } -function resolveIntent(definition: IntentDefinition): ResolvedIntentCommandSpec { +function resolveIntent(definition: IntentDefinition): BuiltIntentCommandDefinition { if (definition.kind === 'robot') { return resolveRobotIntent(definition) } @@ -571,15 +505,7 @@ function resolveIntent(definition: IntentDefinition): ResolvedIntentCommandSpec return resolveTemplateIntent(definition) } -function getOptionFields(spec: ResolvedIntentCommandSpec): readonly GeneratedSchemaField[] { - if (spec.execution.kind === 'dynamic-step') { - return spec.execution.fields - } - - return spec.fieldSpecs -} - -function getBaseClass(spec: ResolvedIntentCommandSpec): IntentBaseClass { +function getBaseClass(spec: BuiltIntentCommandDefinition): IntentBaseClass { if (spec.runnerKind === 'no-input') { return GeneratedNoInputIntentCommand } @@ -595,7 +521,7 @@ function getBaseClass(spec: ResolvedIntentCommandSpec): IntentBaseClass { return GeneratedStandardFileIntentCommand } -function createIntentCommandClass(spec: ResolvedIntentCommandSpec): CommandClass { +function createIntentCommandClass(spec: BuiltIntentCommandDefinition): CommandClass { const BaseClass = getBaseClass(spec) class RuntimeIntentCommand extends BaseClass {} @@ -606,44 +532,7 @@ function createIntentCommandClass(spec: ResolvedIntentCommandSpec): CommandClass Object.assign(RuntimeIntentCommand, { paths: [spec.paths], - intentDefinition: - spec.execution.kind === 'single-step' - ? { - commandLabel: spec.commandLabel, - inputPolicy: spec.input.kind === 'local-files' ? spec.input.inputPolicy : undefined, - outputDescription: spec.outputDescription, - outputMode: spec.outputMode, - execution: { - kind: 'single-step', - schema: spec.schemaSpec?.schema, - fields: spec.fieldSpecs, - fixedValues: spec.execution.fixedValues, - resultStepName: spec.execution.resultStepName, - }, - } - : spec.execution.kind === 'dynamic-step' - ? { - commandLabel: spec.commandLabel, - inputPolicy: spec.input.kind === 'local-files' ? spec.input.inputPolicy : undefined, - outputDescription: spec.outputDescription, - outputMode: spec.outputMode, - execution: { - kind: 'dynamic-step', - handler: spec.execution.handler, - fields: spec.execution.fields, - resultStepName: spec.execution.resultStepName, - }, - } - : { - commandLabel: spec.commandLabel, - inputPolicy: spec.input.kind === 'local-files' ? spec.input.inputPolicy : undefined, - outputDescription: spec.outputDescription, - outputMode: spec.outputMode, - execution: { - kind: 'template', - templateId: spec.execution.templateId, - }, - }, + intentDefinition: spec.intentDefinition, usage: Command.Usage({ category: 'Intent Commands', description: spec.description, @@ -652,7 +541,7 @@ function createIntentCommandClass(spec: ResolvedIntentCommandSpec): CommandClass }), }) - for (const field of getOptionFields(spec)) { + for (const field of getIntentOptionDefinitions(spec.intentDefinition)) { Object.defineProperty(RuntimeIntentCommand.prototype, field.propertyName, { configurable: true, enumerable: true, diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index b09bff4d..78c72ce5 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -22,6 +22,7 @@ import { import type { IntentFieldSpec } from './intentFields.ts' import { coerceIntentFieldValue } from './intentFields.ts' import type { IntentInputPolicy } from './intentInputPolicy.ts' +import { createImageDescribeStep } from './semanticIntents/imageDescribe.ts' export interface PreparedIntentInputs { cleanup: Array<() => Promise> @@ -68,6 +69,18 @@ export interface IntentNoInputCommandDefinition { outputMode?: 'directory' | 'file' } +export type IntentRunnerKind = 'bundled' | 'no-input' | 'standard' | 'watchable' + +export interface IntentCommandDefinition { + className: string + description: string + details: string + examples: Array<[string, string]> + intentDefinition: IntentFileCommandDefinition | IntentNoInputCommandDefinition + paths: string[] + runnerKind: IntentRunnerKind +} + export interface IntentOptionDefinition extends IntentFieldSpec { description?: string optionFlags: string @@ -75,19 +88,6 @@ export interface IntentOptionDefinition extends IntentFieldSpec { required?: boolean } -const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const - -type ImageDescribeField = (typeof imageDescribeFields)[number] - -const wordpressDescribeFields = [ - 'altText', - 'title', - 'caption', - 'description', -] as const satisfies readonly ImageDescribeField[] - -const defaultDescribeModel = 'anthropic/claude-sonnet-4-5' - function isHttpUrl(value: string): boolean { try { const url = new URL(value) @@ -262,215 +262,15 @@ function createSingleStep( }) } -function parseDescribeFields(value: string[] | undefined): ImageDescribeField[] { - const rawFields = (value ?? []) - .flatMap((part) => part.split(',')) - .map((part) => part.trim()) - .filter(Boolean) - - if (rawFields.length === 0) { - return [] - } - - const fields: ImageDescribeField[] = [] - const seen = new Set() - - for (const rawField of rawFields) { - if (!imageDescribeFields.includes(rawField as ImageDescribeField)) { - throw new Error( - `Unsupported --fields value "${rawField}". Supported values: ${imageDescribeFields.join(', ')}`, - ) - } - - const field = rawField as ImageDescribeField - if (seen.has(field)) { - continue - } - - seen.add(field) - fields.push(field) - } - - return fields -} - -function resolveDescribeProfile(profile: string | undefined): 'wordpress' | null { - if (profile == null) { - return null - } - - if (profile === 'wordpress') { - return 'wordpress' - } - - throw new Error(`Unsupported --for value "${profile}". Supported values: wordpress`) -} - -function resolveRequestedDescribeFields({ - explicitFields, - profile, -}: { - explicitFields: ImageDescribeField[] - profile: 'wordpress' | null -}): ImageDescribeField[] { - if ( - explicitFields.length > 0 && - !(explicitFields.length === 1 && explicitFields[0] === 'labels') - ) { - return explicitFields - } - - if (profile === 'wordpress') { - return [...wordpressDescribeFields] - } - - return explicitFields.length === 0 ? ['labels'] : explicitFields -} - -function validateDescribeFields({ - fields, - model, - profile, -}: { - fields: ImageDescribeField[] - model: string - profile: 'wordpress' | null -}): void { - const includesLabels = fields.includes('labels') - - if (includesLabels && fields.length > 1) { - throw new Error( - 'The labels field cannot be combined with altText, title, caption, or description', - ) - } - - if (includesLabels && profile != null) { - throw new Error('--for cannot be combined with --fields labels') - } - - if (includesLabels && model !== defaultDescribeModel) { - throw new Error( - '--model is only supported when generating altText, title, caption, or description', - ) - } -} - -function resolveImageDescribeRequest(rawValues: Record): { - fields: ImageDescribeField[] - profile: 'wordpress' | null -} { - const explicitFields = parseDescribeFields(rawValues.fields as string[] | undefined) - const profile = resolveDescribeProfile(rawValues.forProfile as string | undefined) - const fields = resolveRequestedDescribeFields({ explicitFields, profile }) - validateDescribeFields({ - fields, - model: String(rawValues.model ?? defaultDescribeModel), - profile, - }) - - return { fields, profile } -} - -function buildDescribeAiChatSchema(fields: readonly ImageDescribeField[]): Record { - const properties = Object.fromEntries( - fields.map((field) => { - const description = - field === 'altText' - ? 'A concise accessibility-focused alt text that objectively describes the image' - : field === 'title' - ? 'A concise publishable title for the image' - : field === 'caption' - ? 'A short caption suitable for displaying below the image' - : 'A richer description of the image suitable for CMS usage' - - return [ - field, - { - type: 'string', - description, - }, - ] - }), - ) - - return { - type: 'object', - additionalProperties: false, - required: [...fields], - properties, - } -} - -function buildDescribeAiChatMessages({ - fields, - profile, -}: { - fields: readonly ImageDescribeField[] - profile: 'wordpress' | null -}): { - messages: string - systemMessage: string -} { - const requestedFields = fields.join(', ') - const profileHint = - profile === 'wordpress' - ? 'The output is for the WordPress media library.' - : 'The output is for a publishing workflow.' - - return { - systemMessage: [ - 'You generate accurate image copy for publishing workflows.', - profileHint, - 'Return only the schema fields requested.', - 'Be concrete, concise, and faithful to what is visibly present in the image.', - 'Do not invent facts, brands, locations, or identities that are not clearly visible.', - 'Avoid keyword stuffing, hype, and mentions of SEO or accessibility in the output itself.', - 'For altText, write one objective sentence focused on what matters to someone who cannot see the image.', - 'For title, keep it short and natural.', - 'For caption, write one short sentence suitable for publication.', - 'For description, write one or two sentences with slightly more context than the caption.', - ].join(' '), - messages: `Analyze the attached image and fill these fields: ${requestedFields}.`, - } -} - function createDynamicIntentStep( execution: IntentDynamicStepExecutionDefinition, rawValues: Record, ): Record { - if (execution.handler !== 'image-describe') { - throw new Error(`Unsupported dynamic intent handler "${execution.handler}"`) + if (execution.handler === 'image-describe') { + return createImageDescribeStep(rawValues) } - const { fields, profile } = resolveImageDescribeRequest(rawValues) - if (fields.length === 1 && fields[0] === 'labels') { - return { - robot: '/image/describe', - use: ':original', - result: true, - provider: 'aws', - format: 'json', - granularity: 'list', - explicit_descriptions: false, - } - } - - const { messages, systemMessage } = buildDescribeAiChatMessages({ fields, profile }) - - return { - robot: '/ai/chat', - use: ':original', - result: true, - model: String(rawValues.model ?? defaultDescribeModel), - format: 'json', - return_messages: 'last', - test_credentials: true, - schema: JSON.stringify(buildDescribeAiChatSchema(fields)), - messages, - system_message: systemMessage, - // @TODO Move these inline /ai/chat instructions into a builtin template in api2 and - // switch this command to call that builtin instead of shipping prompt logic in the CLI. - } + throw new Error(`Unsupported dynamic intent handler "${execution.handler}"`) } function requiresLocalInput( diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts new file mode 100644 index 00000000..23154bd1 --- /dev/null +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -0,0 +1,276 @@ +import type { + IntentDynamicStepExecutionDefinition, + IntentOptionDefinition, +} from '../intentRuntime.ts' + +const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const + +type ImageDescribeField = (typeof imageDescribeFields)[number] + +const wordpressDescribeFields = [ + 'altText', + 'title', + 'caption', + 'description', +] as const satisfies readonly ImageDescribeField[] + +const defaultDescribeModel = 'anthropic/claude-sonnet-4-5' + +export const imageDescribeExecutionDefinition = { + kind: 'dynamic-step', + handler: 'image-describe', + resultStepName: 'describe', + fields: [ + { + name: 'fields', + kind: 'string-array', + propertyName: 'fields', + optionFlags: '--fields', + description: + 'Describe output fields to generate, for example labels or altText,title,caption,description', + required: false, + }, + { + name: 'forProfile', + kind: 'string', + propertyName: 'forProfile', + optionFlags: '--for', + description: 'Use a named output profile, currently: wordpress', + required: false, + }, + { + name: 'model', + kind: 'string', + propertyName: 'model', + optionFlags: '--model', + description: 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', + required: false, + }, + ] as const satisfies readonly IntentOptionDefinition[], +} satisfies IntentDynamicStepExecutionDefinition + +export const imageDescribeCommandPresentation = { + description: 'Describe images as labels or publishable text fields', + details: + 'Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`.', + examples: [ + [ + 'Describe an image as labels', + 'transloadit image describe --input hero.jpg --out labels.json', + ], + [ + 'Generate WordPress-ready fields', + 'transloadit image describe --input hero.jpg --for wordpress --out fields.json', + ], + [ + 'Request a custom field set', + 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', + ], + ] as Array<[string, string]>, +} as const + +function parseDescribeFields(value: string[] | undefined): ImageDescribeField[] { + const rawFields = (value ?? []) + .flatMap((part) => part.split(',')) + .map((part) => part.trim()) + .filter(Boolean) + + if (rawFields.length === 0) { + return [] + } + + const fields: ImageDescribeField[] = [] + const seen = new Set() + + for (const rawField of rawFields) { + if (!imageDescribeFields.includes(rawField as ImageDescribeField)) { + throw new Error( + `Unsupported --fields value "${rawField}". Supported values: ${imageDescribeFields.join(', ')}`, + ) + } + + const field = rawField as ImageDescribeField + if (seen.has(field)) { + continue + } + + seen.add(field) + fields.push(field) + } + + return fields +} + +function resolveDescribeProfile(profile: string | undefined): 'wordpress' | null { + if (profile == null) { + return null + } + + if (profile === 'wordpress') { + return 'wordpress' + } + + throw new Error(`Unsupported --for value "${profile}". Supported values: wordpress`) +} + +function resolveRequestedDescribeFields({ + explicitFields, + profile, +}: { + explicitFields: ImageDescribeField[] + profile: 'wordpress' | null +}): ImageDescribeField[] { + if ( + explicitFields.length > 0 && + !(explicitFields.length === 1 && explicitFields[0] === 'labels') + ) { + return explicitFields + } + + if (profile === 'wordpress') { + return [...wordpressDescribeFields] + } + + return explicitFields.length === 0 ? ['labels'] : explicitFields +} + +function validateDescribeFields({ + fields, + model, + profile, +}: { + fields: ImageDescribeField[] + model: string + profile: 'wordpress' | null +}): void { + const includesLabels = fields.includes('labels') + + if (includesLabels && fields.length > 1) { + throw new Error( + 'The labels field cannot be combined with altText, title, caption, or description', + ) + } + + if (includesLabels && profile != null) { + throw new Error('--for cannot be combined with --fields labels') + } + + if (includesLabels && model !== defaultDescribeModel) { + throw new Error( + '--model is only supported when generating altText, title, caption, or description', + ) + } +} + +function resolveImageDescribeRequest(rawValues: Record): { + fields: ImageDescribeField[] + profile: 'wordpress' | null +} { + const explicitFields = parseDescribeFields(rawValues.fields as string[] | undefined) + const profile = resolveDescribeProfile(rawValues.forProfile as string | undefined) + const fields = resolveRequestedDescribeFields({ explicitFields, profile }) + validateDescribeFields({ + fields, + model: String(rawValues.model ?? defaultDescribeModel), + profile, + }) + + return { fields, profile } +} + +function buildDescribeAiChatSchema(fields: readonly ImageDescribeField[]): Record { + const properties = Object.fromEntries( + fields.map((field) => { + const description = + field === 'altText' + ? 'A concise accessibility-focused alt text that objectively describes the image' + : field === 'title' + ? 'A concise publishable title for the image' + : field === 'caption' + ? 'A short caption suitable for displaying below the image' + : 'A richer description of the image suitable for CMS usage' + + return [ + field, + { + type: 'string', + description, + }, + ] + }), + ) + + return { + type: 'object', + additionalProperties: false, + required: [...fields], + properties, + } +} + +function buildDescribeAiChatMessages({ + fields, + profile, +}: { + fields: readonly ImageDescribeField[] + profile: 'wordpress' | null +}): { + messages: string + systemMessage: string +} { + const requestedFields = fields.join(', ') + const profileHint = + profile === 'wordpress' + ? 'The output is for the WordPress media library.' + : 'The output is for a publishing workflow.' + + return { + systemMessage: [ + 'You generate accurate image copy for publishing workflows.', + profileHint, + 'Return only the schema fields requested.', + 'Be concrete, concise, and faithful to what is visibly present in the image.', + 'Do not invent facts, brands, locations, or identities that are not clearly visible.', + 'Avoid keyword stuffing, hype, and mentions of SEO or accessibility in the output itself.', + 'For altText, write one objective sentence focused on what matters to someone who cannot see the image.', + 'For title, keep it short and natural.', + 'For caption, write one short sentence suitable for publication.', + 'For description, write one or two sentences with slightly more context than the caption.', + ].join(' '), + messages: `Analyze the attached image and fill these fields: ${requestedFields}.`, + } +} + +export function createImageDescribeStep( + rawValues: Record, +): Record { + const { fields, profile } = resolveImageDescribeRequest(rawValues) + if (fields.length === 1 && fields[0] === 'labels') { + return { + robot: '/image/describe', + use: ':original', + result: true, + provider: 'aws', + format: 'json', + granularity: 'list', + explicit_descriptions: false, + } + } + + const { messages, systemMessage } = buildDescribeAiChatMessages({ fields, profile }) + + return { + robot: '/ai/chat', + use: ':original', + result: true, + model: String(rawValues.model ?? defaultDescribeModel), + format: 'json', + return_messages: 'last', + test_credentials: true, + schema: JSON.stringify(buildDescribeAiChatSchema(fields)), + messages, + system_message: systemMessage, + // @TODO Move these inline /ai/chat instructions into a builtin template in api2 and + // switch this command to call that builtin instead of shipping prompt logic in the CLI. + } +} From d083526cd3e42c6a8b1317b2467cc1a9d5687e7b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 17:18:35 +0200 Subject: [PATCH 35/44] test(node): add generic json smoke verifier --- packages/node/scripts/test-intents-e2e.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index 88920978..f32c69f0 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -97,6 +97,20 @@ verify_file_decompress() { grep -F 'Hello from Transloadit CLI intents' "$1/input.txt" >/dev/null } +verify_json() { + node --input-type=module <<'NODE' "$1" +import { readFileSync } from 'node:fs' + +const value = JSON.parse(readFileSync(process.argv[1], 'utf8')) +const ok = + value != null && + (!Array.isArray(value) || value.length > 0) && + (typeof value !== 'object' || Object.keys(value).length > 0) + +process.exit(ok ? 0 : 1) +NODE +} + verify_image_describe_labels() { node --input-type=module <<'NODE' "$1" import { readFileSync } from 'node:fs' @@ -131,6 +145,7 @@ verify_output() { local path="$2" case "$verifier" in + json) verify_json "$path" ;; png) verify_png "$path" ;; jpeg) verify_jpeg "$path" ;; pdf) verify_pdf "$path" ;; From 121b085e380d312c6dee488032fd4804eb63a80a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 18:00:25 +0200 Subject: [PATCH 36/44] refactor(node): centralize semantic intents and steps parsing --- packages/node/src/cli/commands/assemblies.ts | 38 ++++-------------- packages/node/src/cli/commands/templates.ts | 23 +++-------- packages/node/src/cli/intentCommandSpecs.ts | 2 +- packages/node/src/cli/intentCommands.ts | 26 ++++++------- packages/node/src/cli/intentRuntime.ts | 10 ++--- .../node/src/cli/semanticIntents/index.ts | 39 +++++++++++++++++++ packages/node/src/cli/stepsInput.ts | 20 ++++++++++ .../test/unit/cli/assemblies-create.test.ts | 34 ++++++++++++++++ 8 files changed, 121 insertions(+), 71 deletions(-) create mode 100644 packages/node/src/cli/semanticIntents/index.ts create mode 100644 packages/node/src/cli/stepsInput.ts diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index c46ef36a..56b02546 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -16,8 +16,7 @@ import * as t from 'typanion' import { z } from 'zod' import { formatLintIssue } from '../../alphalib/assembly-linter.lang.en.ts' import { tryCatch } from '../../alphalib/tryCatch.ts' -import type { Steps, StepsInput } from '../../alphalib/types/template.ts' -import { stepsSchema } from '../../alphalib/types/template.ts' +import type { StepsInput } from '../../alphalib/types/template.ts' import type { CreateAssemblyParams, ReplayAssemblyParams } from '../../apiTypes.ts' import { ensureUniqueCounterValue } from '../../ensureUniqueCounter.ts' import type { LintFatalLevel } from '../../lintAssemblyInstructions.ts' @@ -34,8 +33,9 @@ import { validateSharedFileProcessingOptions, watchOption, } from '../fileProcessingOptions.ts' -import { createReadStream, formatAPIError, readCliInput, streamToBuffer } from '../helpers.ts' +import { formatAPIError, readCliInput } from '../helpers.ts' import type { IOutputCtl } from '../OutputCtl.ts' +import { readStepsInputFile } from '../stepsInput.ts' import { ensureError, isErrnoException } from '../types.ts' import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts' @@ -160,13 +160,7 @@ export async function replay( ): Promise { if (steps) { try { - const buf = await streamToBuffer(createReadStream(steps)) - const parsed: unknown = JSON.parse(buf.toString()) - const validated = stepsSchema.safeParse(parsed) - if (!validated.success) { - throw new Error(`Invalid steps format: ${validated.error.message}`) - } - await apiCall(validated.data) + await apiCall(await readStepsInputFile(steps)) } catch (err) { const error = ensureError(err) output.error(error.message) @@ -175,14 +169,13 @@ export async function replay( await apiCall() } - async function apiCall(stepsOverride?: Steps): Promise { + async function apiCall(stepsOverride?: StepsInput): Promise { const promises = assemblies.map(async (assembly) => { const [err] = await tryCatch( client.replayAssembly(assembly, { reparse_template: reparse ? 1 : 0, fields, notify_url, - // Steps (validated) is assignable to StepsInput at runtime; cast for TS steps: stepsOverride as ReplayAssemblyParams['steps'], }), ) @@ -1253,28 +1246,11 @@ export async function create( if (resolvedOutput === undefined && !process.stdout.isTTY) resolvedOutput = '-' // Read steps file async before entering the Promise constructor - // We use StepsInput (the input type) rather than Steps (the transformed output type) + // We use StepsInput (the input type) rather than the transformed output type // to avoid zod adding default values that the API may reject let effectiveStepsData = stepsData if (steps) { - const stepsContent = await fsp.readFile(steps, 'utf8') - const parsed: unknown = JSON.parse(stepsContent) - // Basic structural validation: must be an object with step names as keys - if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Invalid steps format: expected an object with step names as keys') - } - // Validate each step has a robot field - for (const [stepName, step] of Object.entries(parsed)) { - if (step == null || typeof step !== 'object' || Array.isArray(step)) { - throw new Error(`Invalid steps format: step '${stepName}' must be an object`) - } - if (!('robot' in step) || typeof (step as Record).robot !== 'string') { - throw new Error( - `Invalid steps format: step '${stepName}' must have a 'robot' string property`, - ) - } - } - effectiveStepsData = parsed as StepsInput + effectiveStepsData = await readStepsInputFile(steps) } // Determine output stat async before entering the Promise constructor diff --git a/packages/node/src/cli/commands/templates.ts b/packages/node/src/cli/commands/templates.ts index a2f2bffe..031649b1 100644 --- a/packages/node/src/cli/commands/templates.ts +++ b/packages/node/src/cli/commands/templates.ts @@ -5,12 +5,11 @@ import { Command, Option } from 'clipanion' import rreaddir from 'recursive-readdir' import { z } from 'zod' import { tryCatch } from '../../alphalib/tryCatch.ts' -import type { Steps } from '../../alphalib/types/template.ts' -import { stepsSchema } from '../../alphalib/types/template.ts' import type { TemplateContent } from '../../apiTypes.ts' import type { Transloadit } from '../../Transloadit.ts' import { createReadStream, formatAPIError, streamToBuffer } from '../helpers.ts' import type { IOutputCtl } from '../OutputCtl.ts' +import { parseStepsInputJson } from '../stepsInput.ts' import ModifiedLookup from '../template-last-modified.ts' import type { TemplateFile } from '../types.ts' import { ensureError, isTransloaditAPIError, TemplateFileDataSchema } from '../types.ts' @@ -60,16 +59,11 @@ export async function create( try { const buf = await streamToBuffer(createReadStream(file)) - const parsed: unknown = JSON.parse(buf.toString()) - const validated = stepsSchema.safeParse(parsed) - if (!validated.success) { - throw new Error(`Invalid template steps format: ${validated.error.message}`) - } + const steps = parseStepsInputJson(buf.toString()) const result = await client.createTemplate({ name, - // Steps (validated) is assignable to StepsInput at runtime; cast for TS - template: { steps: validated.data } as TemplateContent, + template: { steps } as TemplateContent, }) output.print(result.id, result) return result @@ -106,23 +100,18 @@ export async function modify( try { const buf = await streamToBuffer(createReadStream(file)) - let steps: Steps | null = null + let steps: TemplateContent['steps'] | null = null let newName = name if (buf.length > 0) { - const parsed: unknown = JSON.parse(buf.toString()) - const validated = stepsSchema.safeParse(parsed) - if (!validated.success) { - throw new Error(`Invalid template steps format: ${validated.error.message}`) - } - steps = validated.data + steps = parseStepsInputJson(buf.toString()) as TemplateContent['steps'] } if (!name || buf.length === 0) { const tpl = await client.getTemplate(template) if (!name) newName = tpl.name if (buf.length === 0 && tpl.content.steps) { - steps = tpl.content.steps + steps = tpl.content.steps as TemplateContent['steps'] } } diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index 1b7dbf5d..9990c61f 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -87,7 +87,7 @@ export interface TemplateIntentDefinition extends IntentBaseDefinition { export interface SemanticIntentDefinition extends IntentBaseDefinition { kind: 'semantic' paths: string[] - semantic: 'image-describe' + semantic: string } export type IntentDefinition = diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index 891f7eec..100380c8 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -29,10 +29,7 @@ import { GeneratedWatchableFileIntentCommand, getIntentOptionDefinitions, } from './intentRuntime.ts' -import { - imageDescribeCommandPresentation, - imageDescribeExecutionDefinition, -} from './semanticIntents/imageDescribe.ts' +import { getSemanticIntentDescriptor } from './semanticIntents/index.ts' interface GeneratedSchemaField extends IntentFieldSpec { description?: string @@ -439,23 +436,22 @@ function resolveRobotIntent(definition: RobotIntentDefinition): BuiltIntentComma } } -function resolveImageDescribeIntent( - definition: SemanticIntentDefinition, -): BuiltIntentCommandDefinition { +function resolveSemanticIntent(definition: SemanticIntentDefinition): BuiltIntentCommandDefinition { const paths = getIntentPaths(definition) + const descriptor = getSemanticIntentDescriptor(definition.semantic) return { className: `${toPascalCase(paths)}Command`, - description: imageDescribeCommandPresentation.description, - details: imageDescribeCommandPresentation.details, - examples: [...imageDescribeCommandPresentation.examples], + description: descriptor.presentation.description, + details: descriptor.presentation.details, + examples: [...descriptor.presentation.examples], paths, - runnerKind: 'watchable', + runnerKind: descriptor.runnerKind, intentDefinition: { commandLabel: paths.join(' '), - execution: imageDescribeExecutionDefinition, - inputPolicy: { kind: 'required' }, - outputDescription: 'Write the JSON result to this path or directory', + execution: descriptor.execution, + inputPolicy: descriptor.inputPolicy, + outputDescription: descriptor.outputDescription, }, } } @@ -499,7 +495,7 @@ function resolveIntent(definition: IntentDefinition): BuiltIntentCommandDefiniti } if (definition.kind === 'semantic') { - return resolveImageDescribeIntent(definition) + return resolveSemanticIntent(definition) } return resolveTemplateIntent(definition) diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 78c72ce5..58da8267 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -22,7 +22,7 @@ import { import type { IntentFieldSpec } from './intentFields.ts' import { coerceIntentFieldValue } from './intentFields.ts' import type { IntentInputPolicy } from './intentInputPolicy.ts' -import { createImageDescribeStep } from './semanticIntents/imageDescribe.ts' +import { getSemanticIntentDescriptor } from './semanticIntents/index.ts' export interface PreparedIntentInputs { cleanup: Array<() => Promise> @@ -40,7 +40,7 @@ export interface IntentSingleStepExecutionDefinition { export interface IntentDynamicStepExecutionDefinition { fields: readonly IntentOptionDefinition[] - handler: 'image-describe' + handler: string kind: 'dynamic-step' resultStepName: string } @@ -266,11 +266,7 @@ function createDynamicIntentStep( execution: IntentDynamicStepExecutionDefinition, rawValues: Record, ): Record { - if (execution.handler === 'image-describe') { - return createImageDescribeStep(rawValues) - } - - throw new Error(`Unsupported dynamic intent handler "${execution.handler}"`) + return getSemanticIntentDescriptor(execution.handler).createStep(rawValues) } function requiresLocalInput( diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts new file mode 100644 index 00000000..76b5adcd --- /dev/null +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -0,0 +1,39 @@ +import type { IntentInputPolicy } from '../intentInputPolicy.ts' +import type { IntentDynamicStepExecutionDefinition, IntentRunnerKind } from '../intentRuntime.ts' +import { + createImageDescribeStep, + imageDescribeCommandPresentation, + imageDescribeExecutionDefinition, +} from './imageDescribe.ts' + +export interface SemanticIntentDescriptor { + createStep: (rawValues: Record) => Record + execution: IntentDynamicStepExecutionDefinition + inputPolicy: IntentInputPolicy + outputDescription: string + presentation: { + description: string + details: string + examples: Array<[string, string]> + } + runnerKind: IntentRunnerKind +} + +export const semanticIntentDescriptors: Record = { + 'image-describe': { + createStep: createImageDescribeStep, + execution: imageDescribeExecutionDefinition, + inputPolicy: { kind: 'required' }, + outputDescription: 'Write the JSON result to this path or directory', + presentation: imageDescribeCommandPresentation, + runnerKind: 'watchable', + }, +} + +export function getSemanticIntentDescriptor(name: string): SemanticIntentDescriptor { + if (!(name in semanticIntentDescriptors)) { + throw new Error(`Semantic intent descriptor does not exist for "${name}"`) + } + + return semanticIntentDescriptors[name] +} diff --git a/packages/node/src/cli/stepsInput.ts b/packages/node/src/cli/stepsInput.ts new file mode 100644 index 00000000..392006a2 --- /dev/null +++ b/packages/node/src/cli/stepsInput.ts @@ -0,0 +1,20 @@ +import fsp from 'node:fs/promises' + +import type { StepsInput } from '../alphalib/types/template.ts' +import { stepsSchema } from '../alphalib/types/template.ts' + +export function parseStepsInputJson(content: string): StepsInput { + const parsed: unknown = JSON.parse(content) + const validated = stepsSchema.safeParse(parsed) + if (!validated.success) { + throw new Error(`Invalid steps format: ${validated.error.message}`) + } + + // Preserve the original input shape so we do not leak zod defaults into API payloads. + return parsed as StepsInput +} + +export async function readStepsInputFile(filePath: string): Promise { + const content = await fsp.readFile(filePath, 'utf8') + return parseStepsInputJson(content) +} diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index a59c3be9..2235ac0b 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -274,6 +274,40 @@ describe('assemblies create', () => { expect(await readFile(outputPath, 'utf8')).toBe('bundle-contents') }) + it('rejects invalid steps files before calling the API', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-invalid-steps-') + const stepsPath = path.join(tempDir, 'steps.json') + + await writeFile( + stepsPath, + JSON.stringify({ + generated: { + robot: '/image/generate', + prompt: 123, + model: 'google/nano-banana', + }, + }), + ) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn(), + awaitAssemblyCompletion: vi.fn(), + } + + await expect( + create(output, client as never, { + inputs: [], + output: path.join(tempDir, 'result.png'), + steps: stepsPath, + }), + ).rejects.toThrow(/Invalid steps format/) + + expect(client.createAssembly).not.toHaveBeenCalled() + }) + it('keeps unchanged inputs in single-assembly rebuilds when one input is stale', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) From a490ab90526a6c1afffc7946fbed30a97bedf784 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 18:30:14 +0200 Subject: [PATCH 37/44] chore(node): refresh image describe default model --- packages/node/src/cli/semanticIntents/imageDescribe.ts | 5 +++-- packages/node/test/unit/cli/intents.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index 23154bd1..02dea7dc 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -14,7 +14,7 @@ const wordpressDescribeFields = [ 'description', ] as const satisfies readonly ImageDescribeField[] -const defaultDescribeModel = 'anthropic/claude-sonnet-4-5' +const defaultDescribeModel = 'anthropic/claude-4-sonnet-20250514' export const imageDescribeExecutionDefinition = { kind: 'dynamic-step', @@ -43,7 +43,8 @@ export const imageDescribeExecutionDefinition = { kind: 'string', propertyName: 'model', optionFlags: '--model', - description: 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-5)', + description: + 'Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514)', required: false, }, ] as const satisfies readonly IntentOptionDefinition[], diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 72db8164..0aaa8281 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -147,7 +147,7 @@ describe('intent commands', () => { robot: '/ai/chat', use: ':original', result: true, - model: 'anthropic/claude-sonnet-4-5', + model: 'anthropic/claude-4-sonnet-20250514', format: 'json', return_messages: 'last', test_credentials: true, From 81f2618d53f8b0be6fab90b4311541006b8ba425 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 20:46:58 +0200 Subject: [PATCH 38/44] fix(node): default image describe to sonnet 4.6 --- packages/node/src/alphalib/types/robots/ai-chat.ts | 1 + packages/node/src/cli/semanticIntents/imageDescribe.ts | 5 ++--- packages/node/test/unit/cli/intents.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/node/src/alphalib/types/robots/ai-chat.ts b/packages/node/src/alphalib/types/robots/ai-chat.ts index af2bc783..7a92b061 100644 --- a/packages/node/src/alphalib/types/robots/ai-chat.ts +++ b/packages/node/src/alphalib/types/robots/ai-chat.ts @@ -148,6 +148,7 @@ export const meta: RobotMetaInput = { export const MODEL_CAPABILITIES: Record = { 'anthropic/claude-4-sonnet-20250514': { pdf: true, image: true }, 'anthropic/claude-4-opus-20250514': { pdf: true, image: true }, + 'anthropic/claude-sonnet-4-6': { pdf: true, image: true }, 'anthropic/claude-sonnet-4-5': { pdf: true, image: true }, 'anthropic/claude-opus-4-5': { pdf: true, image: true }, 'anthropic/claude-opus-4-6': { pdf: true, image: true }, diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index 02dea7dc..db0d55d9 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -14,7 +14,7 @@ const wordpressDescribeFields = [ 'description', ] as const satisfies readonly ImageDescribeField[] -const defaultDescribeModel = 'anthropic/claude-4-sonnet-20250514' +const defaultDescribeModel = 'anthropic/claude-sonnet-4-6' export const imageDescribeExecutionDefinition = { kind: 'dynamic-step', @@ -43,8 +43,7 @@ export const imageDescribeExecutionDefinition = { kind: 'string', propertyName: 'model', optionFlags: '--model', - description: - 'Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514)', + description: 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-6)', required: false, }, ] as const satisfies readonly IntentOptionDefinition[], diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 0aaa8281..c224f7d0 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -147,7 +147,7 @@ describe('intent commands', () => { robot: '/ai/chat', use: ':original', result: true, - model: 'anthropic/claude-4-sonnet-20250514', + model: 'anthropic/claude-sonnet-4-6', format: 'json', return_messages: 'last', test_credentials: true, From 334abc3128defe8d28ccdc7061dbfdb0f53a0bea Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 21:06:36 +0200 Subject: [PATCH 39/44] feat(node): print temporary result urls --- packages/node/src/cli/commands/assemblies.ts | 37 +++++--- packages/node/src/cli/intentRuntime.ts | 43 ++++++++- packages/node/src/cli/resultUrls.ts | 90 +++++++++++++++++++ .../test/unit/cli/assemblies-create.test.ts | 42 +++++++++ packages/node/test/unit/cli/intents.test.ts | 67 ++++++++++++++ 5 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 packages/node/src/cli/resultUrls.ts diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 56b02546..c392a08b 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -35,6 +35,8 @@ import { } from '../fileProcessingOptions.ts' import { formatAPIError, readCliInput } from '../helpers.ts' import type { IOutputCtl } from '../OutputCtl.ts' +import type { ResultUrlRow } from '../resultUrls.ts' +import { collectResultUrlRows, printResultUrls } from '../resultUrls.ts' import { readStepsInputFile } from '../stepsInput.ts' import { ensureError, isErrnoException } from '../types.ts' import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts' @@ -1216,6 +1218,7 @@ export interface AssembliesCreateOptions { reprocessStale?: boolean singleAssembly?: boolean concurrency?: number + printUrls?: boolean } const DEFAULT_CONCURRENCY = 5 @@ -1238,8 +1241,9 @@ export async function create( reprocessStale, singleAssembly, concurrency = DEFAULT_CONCURRENCY, + printUrls: _printUrls, }: AssembliesCreateOptions, -): Promise<{ results: unknown[]; hasFailures: boolean }> { +): Promise<{ resultUrls: ResultUrlRow[]; results: unknown[]; hasFailures: boolean }> { // Quick fix for https://github.com/transloadit/transloadify/issues/13 // Only default to stdout when output is undefined (not provided), not when explicitly null let resolvedOutput = output @@ -1320,6 +1324,7 @@ export async function create( // Use p-queue for concurrency management const queue = new PQueue({ concurrency }) const results: unknown[] = [] + const resultUrls: ResultUrlRow[] = [] let hasFailures = false // AbortController to cancel all in-flight createAssembly calls when an error occurs const abortController = new AbortController() @@ -1336,9 +1341,10 @@ export async function create( return createOptions } - async function awaitCompletedAssembly( - createOptions: CreateAssemblyOptions, - ): Promise>> { + async function awaitCompletedAssembly(createOptions: CreateAssemblyOptions): Promise<{ + assembly: Awaited> + assemblyId: string + }> { const result = await client.createAssembly(createOptions) const assemblyId = result.assembly_id if (!assemblyId) throw new Error('No assembly_id in result') @@ -1357,7 +1363,7 @@ export async function create( throw new Error(msg) } - return assembly + return { assembly, assemblyId } } async function executeAssemblyLifecycle({ @@ -1375,8 +1381,9 @@ export async function create( }): Promise { outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) - const assembly = await awaitCompletedAssembly(createOptions) + const { assembly, assemblyId } = await awaitCompletedAssembly(createOptions) if (!assembly.results) throw new Error('No results in assembly') + resultUrls.push(...collectResultUrlRows({ assemblyId, results: assembly.results })) if ( !singleAssemblyMode && @@ -1454,7 +1461,7 @@ export async function create( emitter.on('end', async () => { if (collectedPaths.length === 0) { - resolve({ results: [], hasFailures: false }) + resolve({ resultUrls, results: [], hasFailures: false }) return } @@ -1468,7 +1475,7 @@ export async function create( }) ) { outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`) - resolve({ results: [], hasFailures: false }) + resolve({ resultUrls, results: [], hasFailures: false }) return } @@ -1507,7 +1514,7 @@ export async function create( outputctl.error(err as Error) } - resolve({ results, hasFailures }) + resolve({ resultUrls, results, hasFailures }) }) } @@ -1531,7 +1538,7 @@ export async function create( emitter.on('end', async () => { await queue.onIdle() - resolve({ results, hasFailures }) + resolve({ resultUrls, results, hasFailures }) }) } @@ -1607,6 +1614,10 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { concurrency = concurrencyOption() + printUrls = Option.Boolean('--print-urls', { + description: 'Print temporary result URLs after completion', + }) + protected async run(): Promise { if (!this.steps && !this.template) { this.output.error('assemblies create requires exactly one of either --steps or --template') @@ -1648,7 +1659,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { return 1 } - const { hasFailures } = await create(this.output, this.client, { + const { hasFailures, resultUrls } = await create(this.output, this.client, { steps: this.steps, template: this.template, fields: fieldsMap, @@ -1660,7 +1671,11 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { reprocessStale: this.reprocessStale, singleAssembly: this.singleAssembly, concurrency: this.concurrency == null ? undefined : Number(this.concurrency), + printUrls: this.printUrls, }) + if (this.printUrls) { + printResultUrls(this.output, resultUrls) + } return hasFailures ? 1 : undefined } } diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 58da8267..31bb4d9f 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -22,6 +22,7 @@ import { import type { IntentFieldSpec } from './intentFields.ts' import { coerceIntentFieldValue } from './intentFields.ts' import type { IntentInputPolicy } from './intentInputPolicy.ts' +import { printResultUrls } from './resultUrls.ts' import { getSemanticIntentDescriptor } from './semanticIntents/index.ts' export interface PreparedIntentInputs { @@ -285,6 +286,7 @@ async function executeIntentCommand({ definition, output, outputPath, + printUrls, rawValues, createOptions, }: { @@ -292,7 +294,8 @@ async function executeIntentCommand({ createOptions: Omit definition: IntentFileCommandDefinition | IntentNoInputCommandDefinition output: AuthenticatedCommand['output'] - outputPath: string + outputPath?: string + printUrls: boolean rawValues: Record }): Promise { const inputPolicy: IntentInputPolicy = @@ -316,12 +319,16 @@ async function executeIntentCommand({ } as AssembliesCreateOptions['stepsData'], } - const { hasFailures } = await assembliesCommands.create(output, client, { + const { hasFailures, resultUrls } = await assembliesCommands.create(output, client, { ...createOptions, - output: outputPath, + output: outputPath ?? null, outputMode: definition.outputMode, + printUrls, ...executionOptions, }) + if (printUrls) { + printResultUrls(output, resultUrls) + } return hasFailures ? 1 : undefined } @@ -330,7 +337,10 @@ abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { outputPath = Option.String('--out,-o', { description: this.getOutputDescription(), - required: true, + }) + + printUrls = Option.Boolean('--print-urls', { + description: 'Print temporary result URLs after completion', }) protected getIntentDefinition(): IntentFileCommandDefinition | IntentNoInputCommandDefinition { @@ -345,10 +355,24 @@ abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { private getOutputDescription(): string { return this.getIntentDefinition().outputDescription } + + protected validateOutputChoice(): number | undefined { + if (this.outputPath == null && !this.printUrls) { + this.output.error('Specify at least one of --out or --print-urls') + return 1 + } + + return undefined + } } export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentCommandBase { protected override async run(): Promise { + const outputValidationError = this.validateOutputChoice() + if (outputValidationError != null) { + return outputValidationError + } + return await executeIntentCommand({ client: this.client, createOptions: { @@ -357,6 +381,7 @@ export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentComma definition: this.getIntentDefinition() as IntentNoInputCommandDefinition, output: this.output, outputPath: this.outputPath, + printUrls: this.printUrls ?? false, rawValues: this.getIntentRawValues(), }) } @@ -472,6 +497,10 @@ export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentComm return this.getIntentDefinition().outputMode } + if (this.outputPath == null) { + return undefined + } + try { return statSync(this.outputPath).isDirectory() ? 'directory' : 'file' } catch { @@ -506,6 +535,11 @@ export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentComm } protected validateBeforePreparingInputs(rawValues: Record): number | undefined { + const outputValidationError = this.validateOutputChoice() + if (outputValidationError != null) { + return outputValidationError + } + const validationError = this.validateInputPresence(rawValues) if (validationError != null) { return validationError @@ -533,6 +567,7 @@ export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentComm definition: this.getIntentDefinition(), output: this.output, outputPath: this.outputPath, + printUrls: this.printUrls ?? false, rawValues, }) } diff --git a/packages/node/src/cli/resultUrls.ts b/packages/node/src/cli/resultUrls.ts new file mode 100644 index 00000000..9f079730 --- /dev/null +++ b/packages/node/src/cli/resultUrls.ts @@ -0,0 +1,90 @@ +import type { IOutputCtl } from './OutputCtl.ts' + +export interface ResultUrlRow { + assemblyId: string + name: string + step: string + url: string +} + +interface ResultFileLike { + name?: unknown + url?: unknown +} + +function isResultFileLike(value: unknown): value is ResultFileLike { + return value != null && typeof value === 'object' +} + +export function collectResultUrlRows({ + assemblyId, + results, +}: { + assemblyId: string + results: unknown +}): ResultUrlRow[] { + if (results == null || typeof results !== 'object' || Array.isArray(results)) { + return [] + } + + const rows: ResultUrlRow[] = [] + + for (const [step, files] of Object.entries(results)) { + if (!Array.isArray(files)) { + continue + } + + for (const file of files) { + if ( + !isResultFileLike(file) || + typeof file.url !== 'string' || + typeof file.name !== 'string' + ) { + continue + } + + rows.push({ + assemblyId, + step, + name: file.name, + url: file.url, + }) + } + } + + return rows +} + +export function formatResultUrlRows(rows: readonly ResultUrlRow[]): string { + if (rows.length === 0) { + return '' + } + + const includeAssembly = new Set(rows.map((row) => row.assemblyId)).size > 1 + const headers = includeAssembly ? ['ASSEMBLY', 'STEP', 'NAME', 'URL'] : ['STEP', 'NAME', 'URL'] + const tableRows = rows.map((row) => + includeAssembly ? [row.assemblyId, row.step, row.name, row.url] : [row.step, row.name, row.url], + ) + + const widths = headers.map((header, index) => + Math.max(header.length, ...tableRows.map((row) => row[index]?.length ?? 0)), + ) + + return [headers, ...tableRows] + .map((row) => + row + .map((value, index) => + index === row.length - 1 ? value : value.padEnd(widths[index] ?? value.length), + ) + .join(' '), + ) + .join('\n') +} + +export function printResultUrls(output: IOutputCtl, rows: readonly ResultUrlRow[]): void { + if (rows.length === 0) { + return + } + + output.print(formatResultUrlRows(rows), { urls: rows }) +} diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 2235ac0b..124d7625 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -136,6 +136,48 @@ describe('assemblies create', () => { expect(resolved).toBe(true) }) + it('returns result URLs for completed assemblies without local output', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-urls' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + generated: [{ url: 'http://downloads.test/result.png', name: 'result.png' }], + }, + }), + } + + await expect( + create(output, client as never, { + inputs: [], + output: null, + stepsData: { + generated: { + robot: '/image/generate', + result: true, + prompt: 'hello', + model: 'flux-schnell', + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + hasFailures: false, + resultUrls: [ + { + assemblyId: 'assembly-urls', + step: 'generated', + name: 'result.png', + url: 'http://downloads.test/result.png', + }, + ], + }), + ) + }) + it('rejects stdout output when an assembly returns multiple files', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index c224f7d0..ad7b310c 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -34,6 +34,7 @@ async function createTempDir(prefix: string): Promise { async function runIntentCommand( args: string[], createResult: Awaited> = { + resultUrls: [], results: [], hasFailures: false, }, @@ -123,6 +124,72 @@ describe('intent commands', () => { ) }) + it('prints aligned result URLs without requiring --out', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { createSpy } = await runIntentCommand( + ['image', 'describe', '--input', 'hero.jpg', '--fields', 'labels', '--print-urls'], + { + results: [], + hasFailures: false, + resultUrls: [ + { + assemblyId: 'assembly-1', + step: 'describe', + name: 'hero.json', + url: 'https://example.com/hero.json', + }, + ], + }, + ) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['hero.jpg'], + output: null, + printUrls: true, + }), + ) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('STEP')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('https://example.com/hero.json')) + }) + + it('prints machine-readable result URLs with --json', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runIntentCommand( + ['--json', 'image', 'describe', '--input', 'hero.jpg', '--fields', 'labels', '--print-urls'], + { + results: [], + hasFailures: false, + resultUrls: [ + { + assemblyId: 'assembly-1', + step: 'describe', + name: 'hero.json', + url: 'https://example.com/hero.json', + }, + ], + }, + ) + + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify({ + urls: [ + { + assemblyId: 'assembly-1', + step: 'describe', + name: 'hero.json', + url: 'https://example.com/hero.json', + }, + ], + }), + ) + }) + it('routes image describe --for wordpress through /ai/chat with a schema', async () => { const { createSpy } = await runIntentCommand([ 'image', From 10245af969379c393eb698c895da9c6bb87a284b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 2 Apr 2026 22:23:20 +0200 Subject: [PATCH 40/44] fix(node): tighten intent url output and downloads --- packages/node/src/cli/resultUrls.ts | 29 +++++-- packages/node/src/inputFiles.ts | 85 ++++++++++++++++++- .../node/test/unit/cli/result-urls.test.ts | 47 ++++++++++ packages/node/test/unit/input-files.test.ts | 25 ++++++ 4 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 packages/node/test/unit/cli/result-urls.test.ts diff --git a/packages/node/src/cli/resultUrls.ts b/packages/node/src/cli/resultUrls.ts index 9f079730..1dd2c52c 100644 --- a/packages/node/src/cli/resultUrls.ts +++ b/packages/node/src/cli/resultUrls.ts @@ -8,7 +8,9 @@ export interface ResultUrlRow { } interface ResultFileLike { + basename?: unknown name?: unknown + ssl_url?: unknown url?: unknown } @@ -35,19 +37,32 @@ export function collectResultUrlRows({ } for (const file of files) { - if ( - !isResultFileLike(file) || - typeof file.url !== 'string' || - typeof file.name !== 'string' - ) { + if (!isResultFileLike(file)) { + continue + } + + const url = + typeof file.ssl_url === 'string' + ? file.ssl_url + : typeof file.url === 'string' + ? file.url + : null + const name = + typeof file.name === 'string' + ? file.name + : typeof file.basename === 'string' + ? file.basename + : null + + if (url == null || name == null) { continue } rows.push({ assemblyId, step, - name: file.name, - url: file.url, + name, + url, }) } } diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index 9e7b6cf9..fd635aff 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -6,6 +6,8 @@ import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import type { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' +import type CacheableLookup from 'cacheable-lookup' +import type { EntryObject, IPFamily } from 'cacheable-lookup' import got from 'got' import type { Input as IntoStreamInput } from 'into-stream' import type { CreateAssemblyParams } from './apiTypes.ts' @@ -154,7 +156,9 @@ const isPrivateIp = (address: string): boolean => { return false } -const assertPublicDownloadUrl = async (value: string): Promise => { +const resolvePublicDownloadAddress = async ( + value: string, +): Promise<{ address: string; family: 4 | 6 }> => { const parsed = new URL(value) if (!['http:', 'https:'].includes(parsed.protocol)) { throw new Error(`URL downloads are limited to http/https: ${value}`) @@ -170,6 +174,16 @@ const assertPublicDownloadUrl = async (value: string): Promise => { if (resolvedAddresses.some((address) => isPrivateIp(address.address))) { throw new Error(`URL downloads are limited to public hosts: ${value}`) } + + const firstAddress = resolvedAddresses[0] + if (firstAddress == null) { + throw new Error(`Unable to resolve URL hostname: ${value}`) + } + + return { + address: firstAddress.address, + family: firstAddress.family as 4 | 6, + } } const downloadUrlToFile = async ({ @@ -184,11 +198,16 @@ const downloadUrlToFile = async ({ let currentUrl = url for (let redirectCount = 0; redirectCount <= MAX_URL_REDIRECTS; redirectCount += 1) { + let validatedAddress: { address: string; family: 4 | 6 } | null = null if (!allowPrivateUrls) { - await assertPublicDownloadUrl(currentUrl) + validatedAddress = await resolvePublicDownloadAddress(currentUrl) } + const dnsLookup: CacheableLookup['lookup'] | undefined = + validatedAddress == null ? undefined : createPinnedDnsLookup(validatedAddress) + const responseStream = got.stream(currentUrl, { + dnsLookup, followRedirect: false, retry: { limit: 0 }, throwHttpErrors: false, @@ -231,6 +250,68 @@ const downloadUrlToFile = async ({ throw new Error(`Too many redirects while downloading URL input: ${url}`) } +function createPinnedDnsLookup(validatedAddress: { + address: string + family: 4 | 6 +}): CacheableLookup['lookup'] { + function pinnedDnsLookup( + _hostname: string, + family: IPFamily, + callback: (error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void, + ): void + function pinnedDnsLookup( + _hostname: string, + callback: (error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void, + ): void + function pinnedDnsLookup( + _hostname: string, + options: { all: true }, + callback: (error: NodeJS.ErrnoException | null, result: ReadonlyArray) => void, + ): void + function pinnedDnsLookup( + _hostname: string, + options: object, + callback: (error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void, + ): void + function pinnedDnsLookup( + _hostname: string, + familyOrCallback: + | IPFamily + | object + | ((error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void), + callback?: + | ((error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void) + | ((error: NodeJS.ErrnoException | null, result: ReadonlyArray) => void), + ): void { + if (typeof familyOrCallback === 'function') { + familyOrCallback(null, validatedAddress.address, validatedAddress.family) + return + } + + if ( + typeof familyOrCallback === 'object' && + familyOrCallback != null && + 'all' in familyOrCallback + ) { + ;( + callback as ( + error: NodeJS.ErrnoException | null, + result: ReadonlyArray, + ) => void + )(null, [{ address: validatedAddress.address, family: validatedAddress.family, expires: 0 }]) + return + } + + ;(callback as (error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void)( + null, + validatedAddress.address, + validatedAddress.family, + ) + } + + return pinnedDnsLookup +} + export const prepareInputFiles = async ( options: PrepareInputFilesOptions = {}, ): Promise => { diff --git a/packages/node/test/unit/cli/result-urls.test.ts b/packages/node/test/unit/cli/result-urls.test.ts new file mode 100644 index 00000000..ed25432a --- /dev/null +++ b/packages/node/test/unit/cli/result-urls.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { collectResultUrlRows, formatResultUrlRows } from '../../../src/cli/resultUrls.ts' + +describe('result url helpers', () => { + it('prefers ssl_url and falls back to basename/name fields', () => { + const rows = collectResultUrlRows({ + assemblyId: 'assembly-1', + results: { + generated: [ + { + basename: 'fallback-name.png', + name: null, + ssl_url: 'https://secure.example.com/file.png', + url: 'http://insecure.example.com/file.png', + }, + ], + }, + }) + + expect(rows).toEqual([ + { + assemblyId: 'assembly-1', + step: 'generated', + name: 'fallback-name.png', + url: 'https://secure.example.com/file.png', + }, + ]) + }) + + it('formats aligned human-readable tables', () => { + const table = formatResultUrlRows([ + { + assemblyId: 'assembly-1', + step: 'describe', + name: 'hero.json', + url: 'https://example.com/hero.json', + }, + ]) + + expect(table).toContain('STEP') + expect(table).toContain('NAME') + expect(table).toContain('URL') + expect(table).toContain('describe') + expect(table).toContain('hero.json') + }) +}) diff --git a/packages/node/test/unit/input-files.test.ts b/packages/node/test/unit/input-files.test.ts index 6eb6e1c5..a498fc45 100644 --- a/packages/node/test/unit/input-files.test.ts +++ b/packages/node/test/unit/input-files.test.ts @@ -154,4 +154,29 @@ describe('prepareInputFiles', () => { expect(publicScope.isDone()).toBe(true) expect(privateScope.isDone()).toBe(false) }) + + it('pins URL downloads to the validated DNS answer', async () => { + lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) + const downloadScope = nock('http://rebind.test').get('/public').reply(200, 'public-data') + + const result = await prepareInputFiles({ + inputFiles: [ + { + kind: 'url', + field: 'remote', + url: 'http://rebind.test/public', + }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }) + + try { + const downloadedPath = result.files.remote + expect(downloadedPath).toBeDefined() + expect(downloadScope.isDone()).toBe(true) + } finally { + await Promise.all(result.cleanup.map((cleanup) => cleanup())) + } + }) }) From 68b87c7d31151fe08773b1d728a3e94edf5b7e5d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 3 Apr 2026 14:36:26 +0200 Subject: [PATCH 41/44] refactor(node): centralize intent field inference --- packages/node/src/cli/intentCommands.ts | 80 +++----- packages/node/src/cli/intentFields.ts | 204 +++++++++++++++++++- packages/node/src/cli/intentRuntime.ts | 32 --- packages/node/test/unit/cli/intents.test.ts | 11 +- 4 files changed, 237 insertions(+), 90 deletions(-) diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index 100380c8..25cbca85 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -13,7 +13,11 @@ import type { } from './intentCommandSpecs.ts' import { getIntentPaths, getIntentResultStepName, intentCatalog } from './intentCommandSpecs.ts' import type { IntentFieldKind, IntentFieldSpec } from './intentFields.ts' -import { inferIntentFieldKind } from './intentFields.ts' +import { + createIntentOption, + inferIntentExampleValue, + inferIntentFieldKind, +} from './intentFields.ts' import type { IntentInputPolicy } from './intentInputPolicy.ts' import type { IntentCommandDefinition, @@ -22,7 +26,6 @@ import type { IntentSingleStepExecutionDefinition, } from './intentRuntime.ts' import { - createIntentOption, GeneratedBundledFileIntentCommand, GeneratedNoInputIntentCommand, GeneratedStandardFileIntentCommand, @@ -33,6 +36,7 @@ import { getSemanticIntentDescriptor } from './semanticIntents/index.ts' interface GeneratedSchemaField extends IntentFieldSpec { description?: string + exampleValue: string optionFlags: string propertyName: string required: boolean @@ -152,37 +156,6 @@ function getDefaultOutputPath(paths: string[], outputMode: IntentOutputMode): st return 'output.file' } -function isIntentPath(paths: string[], expectedGroup: string, expectedAction: string): boolean { - return paths[0] === expectedGroup && paths[1] === expectedAction -} - -function inferPromptExample(paths: string[]): string { - if (isIntentPath(paths, 'image', 'generate')) { - return 'A red bicycle in a studio' - } - - return 'Hello world' -} - -function inferRequiredExampleValue( - paths: string[], - fieldSpec: GeneratedSchemaField, -): string | null { - if (fieldSpec.name === 'aspect_ratio') return '1:1' - if (fieldSpec.name === 'format' && isIntentPath(paths, 'document', 'convert')) return 'pdf' - if (fieldSpec.name === 'format' && isIntentPath(paths, 'file', 'compress')) return 'zip' - if (fieldSpec.name === 'format' && isIntentPath(paths, 'video', 'thumbs')) return 'jpg' - if (fieldSpec.name === 'prompt') return JSON.stringify(inferPromptExample(paths)) - if (fieldSpec.name === 'provider') return 'aws' - if (fieldSpec.name === 'target_language') return 'en-US' - if (fieldSpec.name === 'voice') return 'female-1' - - if (fieldSpec.kind === 'boolean') return 'true' - if (fieldSpec.kind === 'number') return '1' - - return 'value' -} - function inferOutputPath( paths: string[], outputMode: IntentOutputMode, @@ -192,24 +165,18 @@ function inferOutputPath( return 'output/' } - if (isIntentPath(paths, 'file', 'compress')) { - const formatExample = fieldSpecs - .map((fieldSpec) => - fieldSpec.name === 'format' ? inferRequiredExampleValue(paths, fieldSpec) : null, - ) - .find((value) => value != null) - - return `archive.${formatExample ?? 'zip'}` - } - const formatExample = fieldSpecs .map((fieldSpec) => - fieldSpec.required && fieldSpec.name === 'format' - ? inferRequiredExampleValue(paths, fieldSpec) - : null, + fieldSpec.required && fieldSpec.name === 'format' ? fieldSpec.exampleValue : null, ) .find((value) => value != null) + if (fieldSpecs.some((fieldSpec) => fieldSpec.name === 'format') && formatExample != null) { + if (fieldSpecs.some((fieldSpec) => fieldSpec.name === 'relative_pathname')) { + return `archive.${formatExample}` + } + } + if (formatExample != null && /^[-\w]+$/.test(formatExample)) { return `output.${formatExample}` } @@ -318,6 +285,11 @@ function collectSchemaFields( optionFlags: `--${toKebabCase(key)}`, required: (input.kind === 'none' && key === 'prompt') || schemaRequired, description: fieldSchema.description, + exampleValue: inferIntentExampleValue({ + kind, + name: key, + schema: unwrappedSchema as ZodTypeAny, + }), kind, }, ] @@ -344,27 +316,25 @@ function inferExamples( ZodTypeAny > const inputMode = definition.inputMode ?? inferInputModeFromShape(schemaShape) + const fieldSpecs = + spec.intentDefinition.execution.kind === 'single-step' + ? (spec.intentDefinition.execution.fields as readonly GeneratedSchemaField[]) + : [] if (inputMode === 'local-files') { parts.push('--input', getTypicalInputFile(definition.meta)) } if (inputMode === 'none') { - parts.push('--prompt', JSON.stringify(inferPromptExample(spec.paths))) + const promptField = fieldSpecs.find((fieldSpec) => fieldSpec.name === 'prompt') + parts.push('--prompt', promptField?.exampleValue ?? JSON.stringify('A red bicycle in a studio')) } - const fieldSpecs = - spec.intentDefinition.execution.kind === 'single-step' - ? (spec.intentDefinition.execution.fields as readonly GeneratedSchemaField[]) - : [] - for (const fieldSpec of fieldSpecs) { if (!fieldSpec.required) continue if (fieldSpec.name === 'prompt' && inputMode === 'none') continue - const exampleValue = inferRequiredExampleValue(spec.paths, fieldSpec) - if (exampleValue == null) continue - parts.push(fieldSpec.optionFlags, exampleValue) + parts.push(fieldSpec.optionFlags, fieldSpec.exampleValue) } const outputMode = spec.intentDefinition.outputMode ?? 'file' diff --git a/packages/node/src/cli/intentFields.ts b/packages/node/src/cli/intentFields.ts index 5a54da5b..485a6ac9 100644 --- a/packages/node/src/cli/intentFields.ts +++ b/packages/node/src/cli/intentFields.ts @@ -1,12 +1,17 @@ +import { Option } from 'clipanion' +import * as t from 'typanion' import type { z } from 'zod' import { ZodArray, ZodBoolean, + ZodDefault, ZodEffects, ZodEnum, ZodLiteral, + ZodNullable, ZodNumber, ZodObject, + ZodOptional, ZodString, ZodUnion, } from 'zod' @@ -18,6 +23,12 @@ export interface IntentFieldSpec { name: string } +export interface IntentOptionLike extends IntentFieldSpec { + description?: string + optionFlags: string + required?: boolean +} + export function inferIntentFieldKind(schema: unknown): IntentFieldKind { if (schema instanceof ZodEffects) { return inferIntentFieldKind(schema._def.schema) @@ -41,7 +52,16 @@ export function inferIntentFieldKind(schema: unknown): IntentFieldKind { return 'string' } - if (schema instanceof ZodArray || schema instanceof ZodObject) { + if (schema instanceof ZodArray) { + const elementKind = inferIntentFieldKind(schema.element) + if (elementKind === 'string') { + return 'string-array' + } + + return 'json' + } + + if (schema instanceof ZodObject) { return 'json' } @@ -49,6 +69,13 @@ export function inferIntentFieldKind(schema: unknown): IntentFieldKind { const optionKinds = Array.from( new Set(schema._def.options.map((option: unknown) => inferIntentFieldKind(option))), ) as IntentFieldKind[] + if ( + optionKinds.length === 2 && + optionKinds.includes('string') && + optionKinds.includes('string-array') + ) { + return 'string-array' + } if (optionKinds.length === 1) { const [kind] = optionKinds if (kind != null) return kind @@ -59,6 +86,140 @@ export function inferIntentFieldKind(schema: unknown): IntentFieldKind { throw new Error('Unsupported schema type') } +export function createIntentOption(fieldDefinition: IntentOptionLike): unknown { + const { description, kind, optionFlags, required } = fieldDefinition + + if (kind === 'boolean') { + return Option.Boolean(optionFlags, { + description, + required, + }) + } + + if (kind === 'number') { + return Option.String(optionFlags, { + description, + required, + validator: t.isNumber(), + }) + } + + if (kind === 'string-array') { + return Option.Array(optionFlags, { + description, + required, + }) + } + + return Option.String(optionFlags, { + description, + required, + }) +} + +function inferSchemaExampleValue(schema: unknown): string | null { + if (schema instanceof ZodEffects) { + return inferSchemaExampleValue(schema._def.schema) + } + + if (schema instanceof ZodOptional || schema instanceof ZodNullable) { + return inferSchemaExampleValue(schema.unwrap()) + } + + if (schema instanceof ZodDefault) { + return inferSchemaExampleValue(schema.removeDefault()) + } + + if (schema instanceof ZodLiteral) { + return String(schema.value) + } + + if (schema instanceof ZodEnum) { + return schema.options[0] ?? null + } + + if (schema instanceof ZodUnion) { + for (const option of schema._def.options) { + const exampleValue = inferSchemaExampleValue(option) + if (exampleValue != null) { + return exampleValue + } + } + } + + return null +} + +function pickPreferredExampleValue(name: string, candidates: readonly string[]): string | null { + if (candidates.length === 0) { + return null + } + + if (name === 'format') { + const preferredFormats = ['pdf', 'zip', 'jpg', 'png', 'mp3'] + for (const preferredFormat of preferredFormats) { + if (candidates.includes(preferredFormat)) { + return preferredFormat + } + } + } + + return candidates[0] ?? null +} + +export function inferIntentExampleValue({ + kind, + name, + schema, +}: { + kind: IntentFieldKind + name: string + schema?: z.ZodTypeAny +}): string { + if (name === 'prompt') { + return JSON.stringify('A red bicycle in a studio') + } + + if (name === 'provider') { + return 'aws' + } + + if (name === 'target_language') { + return 'en-US' + } + + if (name === 'voice') { + return 'female-1' + } + + const schemaExample = + schema instanceof ZodEnum + ? pickPreferredExampleValue(name, schema.options) + : schema instanceof ZodUnion + ? pickPreferredExampleValue( + name, + schema._def.options + .map((option: unknown) => inferSchemaExampleValue(option)) + .filter((value: string | null): value is string => value != null), + ) + : schema == null + ? null + : inferSchemaExampleValue(schema) + if (schemaExample != null) { + return schemaExample + } + + if (kind === 'boolean') { + return 'true' + } + + if (kind === 'number') { + return '1' + } + + return 'value' +} + export function coerceIntentFieldValue( kind: IntentFieldKind, raw: unknown, @@ -171,7 +332,46 @@ export function coerceIntentFieldValue( if (kind === 'string-array') { if (Array.isArray(raw)) { - return raw + if (raw.length === 1 && typeof raw[0] === 'string') { + const trimmed = raw[0].trim() + if (trimmed.startsWith('[')) { + let parsedJson: unknown + try { + parsedJson = JSON.parse(trimmed) + } catch { + throw new Error(`Expected valid JSON but received "${raw[0]}"`) + } + + if ( + !Array.isArray(parsedJson) || + !parsedJson.every((value) => typeof value === 'string') + ) { + throw new Error(`Expected an array of strings but received "${raw[0]}"`) + } + + return parsedJson + } + } + + return raw.map((value) => String(value)) + } + + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (trimmed.startsWith('[')) { + let parsedJson: unknown + try { + parsedJson = JSON.parse(trimmed) + } catch { + throw new Error(`Expected valid JSON but received "${raw}"`) + } + + if (!Array.isArray(parsedJson) || !parsedJson.every((value) => typeof value === 'string')) { + throw new Error(`Expected an array of strings but received "${raw}"`) + } + + return parsedJson + } } return [String(raw)] diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 31bb4d9f..e0205292 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -1,7 +1,6 @@ import { statSync } from 'node:fs' import { basename } from 'node:path' import { Option } from 'clipanion' -import * as t from 'typanion' import type { z } from 'zod' import { prepareInputFiles } from '../inputFiles.ts' @@ -387,37 +386,6 @@ export abstract class GeneratedNoInputIntentCommand extends GeneratedIntentComma } } -export function createIntentOption(fieldDefinition: IntentOptionDefinition): unknown { - const { description, kind, optionFlags, required } = fieldDefinition - - if (kind === 'boolean') { - return Option.Boolean(optionFlags, { - description, - required, - }) - } - - if (kind === 'number') { - return Option.String(optionFlags, { - description, - required, - validator: t.isNumber(), - }) - } - - if (kind === 'string-array') { - return Option.Array(optionFlags, { - description, - required, - }) - } - - return Option.String(optionFlags, { - description, - required, - }) -} - export function getIntentOptionDefinitions( definition: IntentFileCommandDefinition | IntentNoInputCommandDefinition, ): readonly IntentOptionDefinition[] { diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index ad7b310c..6732c898 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import * as assembliesCommands from '../../../src/cli/commands/assemblies.ts' import { @@ -12,7 +13,7 @@ import { intentCatalog, } from '../../../src/cli/intentCommandSpecs.ts' import { intentCommands } from '../../../src/cli/intentCommands.ts' -import { coerceIntentFieldValue } from '../../../src/cli/intentFields.ts' +import { coerceIntentFieldValue, inferIntentFieldKind } from '../../../src/cli/intentFields.ts' import { prepareIntentInputs } from '../../../src/cli/intentRuntime.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' @@ -817,6 +818,11 @@ describe('intent commands', () => { expect(() => coerceIntentFieldValue('number', ' ')).toThrow('Expected a number') }) + it('classifies string array schemas as string-array intent fields', () => { + expect(inferIntentFieldKind(z.array(z.string()))).toBe('string-array') + expect(inferIntentFieldKind(z.union([z.string(), z.array(z.string())]))).toBe('string-array') + }) + it('parses JSON objects for auto-typed flags like image resize --crop', async () => { const { createSpy } = await runIntentCommand([ 'image', @@ -1030,6 +1036,9 @@ describe('intent commands', () => { expect(getIntentCommand(['text', 'speak']).usage.examples).toEqual([ ['Run the command', expect.stringContaining('--provider')], ]) + expect(getIntentCommand(['document', 'convert']).usage.examples).toEqual([ + ['Run the command', expect.stringContaining('output.pdf')], + ]) }) it('keeps the catalog, generated commands, and smoke cases in sync', () => { From a8559bdff1e13e553ee3deb23bc8b771ab14a489 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 3 Apr 2026 15:52:00 +0200 Subject: [PATCH 42/44] refactor(node): share intent parsing helpers --- packages/node/src/cli/commands/assemblies.ts | 62 ++---- packages/node/src/cli/intentCommands.ts | 40 +--- packages/node/src/cli/intentFields.ts | 177 ++++++++++-------- packages/node/src/cli/resultFiles.ts | 93 +++++++++ packages/node/src/cli/resultUrls.ts | 61 +----- .../src/cli/semanticIntents/imageDescribe.ts | 6 +- packages/node/test/unit/cli/intents.test.ts | 16 +- 7 files changed, 240 insertions(+), 215 deletions(-) create mode 100644 packages/node/src/cli/resultFiles.ts diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index c392a08b..0f07f390 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -35,6 +35,8 @@ import { } from '../fileProcessingOptions.ts' import { formatAPIError, readCliInput } from '../helpers.ts' import type { IOutputCtl } from '../OutputCtl.ts' +import type { AssemblyResultEntryLike, NormalizedAssemblyResultFile } from '../resultFiles.ts' +import { flattenAssemblyResultFiles } from '../resultFiles.ts' import type { ResultUrlRow } from '../resultUrls.ts' import { collectResultUrlRows, printResultUrls } from '../resultUrls.ts' import { readStepsInputFile } from '../stepsInput.ts' @@ -492,21 +494,6 @@ async function downloadResultToStdout(resultUrl: string, signal: AbortSignal): P await pipeline(got.stream(resultUrl, { signal }), stdoutStream) } -interface AssemblyResultFile { - file: { - basename?: string | null - ext?: string | null - name?: string | null - ssl_url?: string | null - url?: string | null - } - stepName: string -} - -function getResultFileUrl(file: AssemblyResultFile['file']): string | null { - return file.ssl_url ?? file.url ?? null -} - function sanitizeResultName(value: string): string { const base = path.basename(value) return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '') @@ -531,28 +518,18 @@ async function ensureUniquePath(targetPath: string, reservedPaths: Set): }) } -function flattenAssemblyResults(results: Record>): { - allFiles: AssemblyResultFile[] - entries: Array<[string, Array]> +function flattenAssemblyResults(results: Record>): { + allFiles: NormalizedAssemblyResultFile[] + entries: Array<[string, Array]> } { - const entries = Object.entries(results) - const allFiles: AssemblyResultFile[] = [] - for (const [stepName, stepResults] of entries) { - for (const file of stepResults) { - allFiles.push({ stepName, file }) - } + return { + allFiles: flattenAssemblyResultFiles(results), + entries: Object.entries(results), } - - return { allFiles, entries } } -function getResultFileName({ file, stepName }: AssemblyResultFile): string { - const rawName = - file.name ?? - (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ?? - `${stepName}_result` - - return sanitizeResultName(rawName) +function getResultFileName(file: NormalizedAssemblyResultFile): string { + return sanitizeResultName(file.name) } interface AssemblyDownloadTarget { @@ -571,7 +548,7 @@ async function buildDirectoryDownloadTargets({ baseDir, groupByStep, }: { - allFiles: AssemblyResultFile[] + allFiles: NormalizedAssemblyResultFile[] baseDir: string groupByStep: boolean }): Promise { @@ -580,16 +557,11 @@ async function buildDirectoryDownloadTargets({ const targets: AssemblyDownloadTarget[] = [] const reservedPaths = new Set() for (const resultFile of allFiles) { - const resultUrl = getResultFileUrl(resultFile.file) - if (resultUrl == null) { - continue - } - const targetDir = groupByStep ? path.join(baseDir, resultFile.stepName) : baseDir await fsp.mkdir(targetDir, { recursive: true }) targets.push({ - resultUrl, + resultUrl: resultFile.url, targetPath: await ensureUniquePath( path.join(targetDir, getResultFileName(resultFile)), reservedPaths, @@ -601,11 +573,11 @@ async function buildDirectoryDownloadTargets({ } function getSingleResultDownloadTarget( - allFiles: AssemblyResultFile[], + allFiles: NormalizedAssemblyResultFile[], targetPath: string | null, ): AssemblyDownloadTarget[] { const first = allFiles[0] - const resultUrl = first == null ? null : getResultFileUrl(first.file) + const resultUrl = first?.url ?? null if (resultUrl == null) { return [] } @@ -625,8 +597,8 @@ async function resolveResultDownloadTargets({ outputRootIsDirectory, singleAssembly, }: { - allFiles: AssemblyResultFile[] - entries: Array<[string, Array]> + allFiles: NormalizedAssemblyResultFile[] + entries: Array<[string, Array]> hasDirectoryInput: boolean inPath: string | null inputs: string[] @@ -764,7 +736,7 @@ async function materializeAssemblyResults({ outputRoot: string | null outputRootIsDirectory: boolean outputctl: IOutputCtl - results: Record> + results: Record> singleAssembly?: boolean }): Promise { if (outputRoot == null) { diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index 25cbca85..aca341f4 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -1,7 +1,6 @@ import type { CommandClass } from 'clipanion' import { Command } from 'clipanion' import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod' -import { ZodDefault, ZodEffects, ZodNullable, ZodOptional } from 'zod' import type { RobotMetaInput } from '../alphalib/types/robots/_instructions-primitives.ts' import type { @@ -17,6 +16,7 @@ import { createIntentOption, inferIntentExampleValue, inferIntentFieldKind, + unwrapIntentSchema, } from './intentFields.ts' import type { IntentInputPolicy } from './intentInputPolicy.ts' import type { @@ -96,38 +96,6 @@ function stripTrailingPunctuation(value: string): string { return value.replace(/[.:]+$/, '').trim() } -function unwrapSchema(input: unknown): { required: boolean; schema: unknown } { - let schema = input - let required = true - - while (true) { - if (schema instanceof ZodEffects) { - schema = schema._def.schema - continue - } - - if (schema instanceof ZodOptional) { - required = false - schema = schema.unwrap() - continue - } - - if (schema instanceof ZodDefault) { - required = false - schema = schema.removeDefault() - continue - } - - if (schema instanceof ZodNullable) { - required = false - schema = schema.unwrap() - continue - } - - return { required, schema } - } -} - function getTypicalInputFile(meta: RobotMetaInput): string { switch (meta.typical_file_type) { case 'audio file': @@ -186,7 +154,7 @@ function inferOutputPath( function inferInputModeFromShape(shape: Record): IntentInputMode { if ('prompt' in shape) { - return unwrapSchema(shape.prompt).required ? 'none' : 'local-files' + return unwrapIntentSchema(shape.prompt).required ? 'none' : 'local-files' } return 'local-files' @@ -201,7 +169,7 @@ function inferIntentInput( return { kind: 'none' } } - const promptIsOptional = 'prompt' in shape && !unwrapSchema(shape.prompt).required + const promptIsOptional = 'prompt' in shape && !unwrapIntentSchema(shape.prompt).required const inputPolicy = promptIsOptional ? ({ kind: 'optional', @@ -269,7 +237,7 @@ function collectSchemaFields( return Object.entries(schemaShape) .filter(([key]) => !hiddenFieldNames.has(key) && !Object.hasOwn(fixedValues, key)) .flatMap(([key, fieldSchema]) => { - const { required: schemaRequired, schema: unwrappedSchema } = unwrapSchema(fieldSchema) + const { required: schemaRequired, schema: unwrappedSchema } = unwrapIntentSchema(fieldSchema) let kind: IntentFieldKind try { diff --git a/packages/node/src/cli/intentFields.ts b/packages/node/src/cli/intentFields.ts index 485a6ac9..2bb5574a 100644 --- a/packages/node/src/cli/intentFields.ts +++ b/packages/node/src/cli/intentFields.ts @@ -29,31 +29,61 @@ export interface IntentOptionLike extends IntentFieldSpec { required?: boolean } -export function inferIntentFieldKind(schema: unknown): IntentFieldKind { - if (schema instanceof ZodEffects) { - return inferIntentFieldKind(schema._def.schema) +export function unwrapIntentSchema(input: unknown): { required: boolean; schema: unknown } { + let schema = input + let required = true + + while (true) { + if (schema instanceof ZodEffects) { + schema = schema._def.schema + continue + } + + if (schema instanceof ZodOptional) { + required = false + schema = schema.unwrap() + continue + } + + if (schema instanceof ZodDefault) { + required = false + schema = schema.removeDefault() + continue + } + + if (schema instanceof ZodNullable) { + required = false + schema = schema.unwrap() + continue + } + + return { required, schema } } +} + +export function inferIntentFieldKind(schema: unknown): IntentFieldKind { + const unwrappedSchema = unwrapIntentSchema(schema).schema - if (schema instanceof ZodString || schema instanceof ZodEnum) { + if (unwrappedSchema instanceof ZodString || unwrappedSchema instanceof ZodEnum) { return 'string' } - if (schema instanceof ZodNumber) { + if (unwrappedSchema instanceof ZodNumber) { return 'number' } - if (schema instanceof ZodBoolean) { + if (unwrappedSchema instanceof ZodBoolean) { return 'boolean' } - if (schema instanceof ZodLiteral) { - if (typeof schema.value === 'number') return 'number' - if (typeof schema.value === 'boolean') return 'boolean' + if (unwrappedSchema instanceof ZodLiteral) { + if (typeof unwrappedSchema.value === 'number') return 'number' + if (typeof unwrappedSchema.value === 'boolean') return 'boolean' return 'string' } - if (schema instanceof ZodArray) { - const elementKind = inferIntentFieldKind(schema.element) + if (unwrappedSchema instanceof ZodArray) { + const elementKind = inferIntentFieldKind(unwrappedSchema.element) if (elementKind === 'string') { return 'string-array' } @@ -61,13 +91,13 @@ export function inferIntentFieldKind(schema: unknown): IntentFieldKind { return 'json' } - if (schema instanceof ZodObject) { + if (unwrappedSchema instanceof ZodObject) { return 'json' } - if (schema instanceof ZodUnion) { + if (unwrappedSchema instanceof ZodUnion) { const optionKinds = Array.from( - new Set(schema._def.options.map((option: unknown) => inferIntentFieldKind(option))), + new Set(unwrappedSchema._def.options.map((option: unknown) => inferIntentFieldKind(option))), ) as IntentFieldKind[] if ( optionKinds.length === 2 && @@ -118,28 +148,18 @@ export function createIntentOption(fieldDefinition: IntentOptionLike): unknown { } function inferSchemaExampleValue(schema: unknown): string | null { - if (schema instanceof ZodEffects) { - return inferSchemaExampleValue(schema._def.schema) - } - - if (schema instanceof ZodOptional || schema instanceof ZodNullable) { - return inferSchemaExampleValue(schema.unwrap()) - } + const unwrappedSchema = unwrapIntentSchema(schema).schema - if (schema instanceof ZodDefault) { - return inferSchemaExampleValue(schema.removeDefault()) + if (unwrappedSchema instanceof ZodLiteral) { + return String(unwrappedSchema.value) } - if (schema instanceof ZodLiteral) { - return String(schema.value) + if (unwrappedSchema instanceof ZodEnum) { + return unwrappedSchema.options[0] ?? null } - if (schema instanceof ZodEnum) { - return schema.options[0] ?? null - } - - if (schema instanceof ZodUnion) { - for (const option of schema._def.options) { + if (unwrappedSchema instanceof ZodUnion) { + for (const option of unwrappedSchema._def.options) { const exampleValue = inferSchemaExampleValue(option) if (exampleValue != null) { return exampleValue @@ -150,6 +170,56 @@ function inferSchemaExampleValue(schema: unknown): string | null { return null } +export function parseStringArrayValue(raw: unknown): string[] { + const addNormalizedValues = (source: string[], value: string): void => { + source.push( + ...value + .split(',') + .map((part) => part.trim()) + .filter(Boolean), + ) + } + + const normalizeJsonArray = (value: string): string[] | null => { + const trimmed = value.trim() + if (!trimmed.startsWith('[')) { + return null + } + + let parsedJson: unknown + try { + parsedJson = JSON.parse(trimmed) + } catch { + throw new Error(`Expected valid JSON but received "${value}"`) + } + + if (!Array.isArray(parsedJson) || !parsedJson.every((item) => typeof item === 'string')) { + throw new Error(`Expected an array of strings but received "${value}"`) + } + + return parsedJson + } + + const values = Array.isArray(raw) ? raw : [raw] + const normalizedValues: string[] = [] + for (const value of values) { + if (typeof value !== 'string') { + normalizedValues.push(String(value)) + continue + } + + const parsedJson = normalizeJsonArray(value) + if (parsedJson != null) { + normalizedValues.push(...parsedJson) + continue + } + + addNormalizedValues(normalizedValues, value) + } + + return normalizedValues +} + function pickPreferredExampleValue(name: string, candidates: readonly string[]): string | null { if (candidates.length === 0) { return null @@ -331,50 +401,7 @@ export function coerceIntentFieldValue( } if (kind === 'string-array') { - if (Array.isArray(raw)) { - if (raw.length === 1 && typeof raw[0] === 'string') { - const trimmed = raw[0].trim() - if (trimmed.startsWith('[')) { - let parsedJson: unknown - try { - parsedJson = JSON.parse(trimmed) - } catch { - throw new Error(`Expected valid JSON but received "${raw[0]}"`) - } - - if ( - !Array.isArray(parsedJson) || - !parsedJson.every((value) => typeof value === 'string') - ) { - throw new Error(`Expected an array of strings but received "${raw[0]}"`) - } - - return parsedJson - } - } - - return raw.map((value) => String(value)) - } - - if (typeof raw === 'string') { - const trimmed = raw.trim() - if (trimmed.startsWith('[')) { - let parsedJson: unknown - try { - parsedJson = JSON.parse(trimmed) - } catch { - throw new Error(`Expected valid JSON but received "${raw}"`) - } - - if (!Array.isArray(parsedJson) || !parsedJson.every((value) => typeof value === 'string')) { - throw new Error(`Expected an array of strings but received "${raw}"`) - } - - return parsedJson - } - } - - return [String(raw)] + return parseStringArrayValue(raw) } return raw diff --git a/packages/node/src/cli/resultFiles.ts b/packages/node/src/cli/resultFiles.ts new file mode 100644 index 00000000..ed6d1a7d --- /dev/null +++ b/packages/node/src/cli/resultFiles.ts @@ -0,0 +1,93 @@ +export interface AssemblyResultEntryLike { + basename?: unknown + ext?: unknown + name?: unknown + ssl_url?: unknown + url?: unknown +} + +export interface NormalizedAssemblyResultFile { + file: AssemblyResultEntryLike + name: string + stepName: string + url: string +} + +function isAssemblyResultEntryLike(value: unknown): value is AssemblyResultEntryLike { + return value != null && typeof value === 'object' +} + +function normalizeAssemblyResultName( + stepName: string, + file: AssemblyResultEntryLike, +): string | null { + if (typeof file.name === 'string') { + return file.name + } + + if (typeof file.basename === 'string') { + if (typeof file.ext === 'string' && file.ext.length > 0) { + return `${file.basename}.${file.ext}` + } + + return file.basename + } + + return `${stepName}_result` +} + +function normalizeAssemblyResultUrl(file: AssemblyResultEntryLike): string | null { + if (typeof file.ssl_url === 'string') { + return file.ssl_url + } + + if (typeof file.url === 'string') { + return file.url + } + + return null +} + +export function normalizeAssemblyResultFile( + stepName: string, + value: unknown, +): NormalizedAssemblyResultFile | null { + if (!isAssemblyResultEntryLike(value)) { + return null + } + + const url = normalizeAssemblyResultUrl(value) + const name = normalizeAssemblyResultName(stepName, value) + if (url == null || name == null) { + return null + } + + return { + file: value, + name, + stepName, + url, + } +} + +export function flattenAssemblyResultFiles(results: unknown): NormalizedAssemblyResultFile[] { + if (results == null || typeof results !== 'object' || Array.isArray(results)) { + return [] + } + + const files: NormalizedAssemblyResultFile[] = [] + for (const [stepName, stepResults] of Object.entries(results)) { + if (!Array.isArray(stepResults)) { + continue + } + + for (const stepResult of stepResults) { + const normalized = normalizeAssemblyResultFile(stepName, stepResult) + if (normalized != null) { + files.push(normalized) + } + } + } + + return files +} diff --git a/packages/node/src/cli/resultUrls.ts b/packages/node/src/cli/resultUrls.ts index 1dd2c52c..b500a666 100644 --- a/packages/node/src/cli/resultUrls.ts +++ b/packages/node/src/cli/resultUrls.ts @@ -1,4 +1,5 @@ import type { IOutputCtl } from './OutputCtl.ts' +import { flattenAssemblyResultFiles } from './resultFiles.ts' export interface ResultUrlRow { assemblyId: string @@ -7,17 +8,6 @@ export interface ResultUrlRow { url: string } -interface ResultFileLike { - basename?: unknown - name?: unknown - ssl_url?: unknown - url?: unknown -} - -function isResultFileLike(value: unknown): value is ResultFileLike { - return value != null && typeof value === 'object' -} - export function collectResultUrlRows({ assemblyId, results, @@ -25,49 +15,12 @@ export function collectResultUrlRows({ assemblyId: string results: unknown }): ResultUrlRow[] { - if (results == null || typeof results !== 'object' || Array.isArray(results)) { - return [] - } - - const rows: ResultUrlRow[] = [] - - for (const [step, files] of Object.entries(results)) { - if (!Array.isArray(files)) { - continue - } - - for (const file of files) { - if (!isResultFileLike(file)) { - continue - } - - const url = - typeof file.ssl_url === 'string' - ? file.ssl_url - : typeof file.url === 'string' - ? file.url - : null - const name = - typeof file.name === 'string' - ? file.name - : typeof file.basename === 'string' - ? file.basename - : null - - if (url == null || name == null) { - continue - } - - rows.push({ - assemblyId, - step, - name, - url, - }) - } - } - - return rows + return flattenAssemblyResultFiles(results).map((file) => ({ + assemblyId, + step: file.stepName, + name: file.name, + url: file.url, + })) } export function formatResultUrlRows(rows: readonly ResultUrlRow[]): string { diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index db0d55d9..c35b3cf6 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -1,3 +1,4 @@ +import { parseStringArrayValue } from '../intentFields.ts' import type { IntentDynamicStepExecutionDefinition, IntentOptionDefinition, @@ -70,10 +71,7 @@ export const imageDescribeCommandPresentation = { } as const function parseDescribeFields(value: string[] | undefined): ImageDescribeField[] { - const rawFields = (value ?? []) - .flatMap((part) => part.split(',')) - .map((part) => part.trim()) - .filter(Boolean) + const rawFields = parseStringArrayValue(value ?? []) if (rawFields.length === 0) { return [] diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 6732c898..0469528c 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -13,7 +13,11 @@ import { intentCatalog, } from '../../../src/cli/intentCommandSpecs.ts' import { intentCommands } from '../../../src/cli/intentCommands.ts' -import { coerceIntentFieldValue, inferIntentFieldKind } from '../../../src/cli/intentFields.ts' +import { + coerceIntentFieldValue, + inferIntentFieldKind, + parseStringArrayValue, +} from '../../../src/cli/intentFields.ts' import { prepareIntentInputs } from '../../../src/cli/intentRuntime.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' @@ -823,6 +827,16 @@ describe('intent commands', () => { expect(inferIntentFieldKind(z.union([z.string(), z.array(z.string())]))).toBe('string-array') }) + it('parses shared string-array values from csv, repeated flags, and JSON arrays', () => { + expect(parseStringArrayValue('altText,title')).toEqual(['altText', 'title']) + expect(parseStringArrayValue(['altText,title', 'caption'])).toEqual([ + 'altText', + 'title', + 'caption', + ]) + expect(parseStringArrayValue(['["altText","title"]'])).toEqual(['altText', 'title']) + }) + it('parses JSON objects for auto-typed flags like image resize --crop', async () => { const { createSpy } = await runIntentCommand([ 'image', From e0bcac6ef93d726282ece1a50784fc37e1de8013 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 3 Apr 2026 16:28:11 +0200 Subject: [PATCH 43/44] refactor(node): keep url printing at command layer --- packages/node/src/cli/commands/assemblies.ts | 3 --- packages/node/src/cli/intentRuntime.ts | 1 - packages/node/test/unit/cli/intents.test.ts | 1 - 3 files changed, 5 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 0f07f390..a4ec77b7 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1190,7 +1190,6 @@ export interface AssembliesCreateOptions { reprocessStale?: boolean singleAssembly?: boolean concurrency?: number - printUrls?: boolean } const DEFAULT_CONCURRENCY = 5 @@ -1213,7 +1212,6 @@ export async function create( reprocessStale, singleAssembly, concurrency = DEFAULT_CONCURRENCY, - printUrls: _printUrls, }: AssembliesCreateOptions, ): Promise<{ resultUrls: ResultUrlRow[]; results: unknown[]; hasFailures: boolean }> { // Quick fix for https://github.com/transloadit/transloadify/issues/13 @@ -1643,7 +1641,6 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { reprocessStale: this.reprocessStale, singleAssembly: this.singleAssembly, concurrency: this.concurrency == null ? undefined : Number(this.concurrency), - printUrls: this.printUrls, }) if (this.printUrls) { printResultUrls(this.output, resultUrls) diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index e0205292..06433234 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -322,7 +322,6 @@ async function executeIntentCommand({ ...createOptions, output: outputPath ?? null, outputMode: definition.outputMode, - printUrls, ...executionOptions, }) if (printUrls) { diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 0469528c..eb087c21 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -155,7 +155,6 @@ describe('intent commands', () => { expect.objectContaining({ inputs: ['hero.jpg'], output: null, - printUrls: true, }), ) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('STEP')) From db449eecd5866ba91a0f425e79f95cb82a868779 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 3 Apr 2026 19:34:07 +0200 Subject: [PATCH 44/44] fix(node): honor describe labels and stale bundles --- packages/node/src/cli/commands/assemblies.ts | 14 +++++- .../src/cli/semanticIntents/imageDescribe.ts | 5 +- .../test/unit/cli/assemblies-create.test.ts | 49 +++++++++++++++++++ packages/node/test/unit/cli/intents.test.ts | 18 +++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index a4ec77b7..01542755 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -671,12 +671,14 @@ async function shouldSkipStaleOutput({ outputPlanMtime, outputRootIsDirectory, reprocessStale, + singleInputReference = 'output-plan', }: { inputPaths: string[] outputPath: string | null outputPlanMtime: Date outputRootIsDirectory: boolean reprocessStale?: boolean + singleInputReference?: 'input' | 'output-plan' }): Promise { if (reprocessStale || outputPath == null || outputRootIsDirectory) { return false @@ -692,7 +694,16 @@ async function shouldSkipStaleOutput({ } if (inputPaths.length === 1) { - return isMeaningfullyNewer(outputStat.mtime, outputPlanMtime) + if (singleInputReference === 'output-plan') { + return isMeaningfullyNewer(outputStat.mtime, outputPlanMtime) + } + + const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPaths[0])) + if (inputErr != null || inputStat == null) { + return false + } + + return isMeaningfullyNewer(outputStat.mtime, inputStat.mtime) } const inputStats = await Promise.all( @@ -1442,6 +1453,7 @@ export async function create( outputPlanMtime: new Date(0), outputRootIsDirectory, reprocessStale, + singleInputReference: 'input', }) ) { outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`) diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index c35b3cf6..f63191dc 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -118,10 +118,7 @@ function resolveRequestedDescribeFields({ explicitFields: ImageDescribeField[] profile: 'wordpress' | null }): ImageDescribeField[] { - if ( - explicitFields.length > 0 && - !(explicitFields.length === 1 && explicitFields[0] === 'labels') - ) { + if (explicitFields.length > 0) { return explicitFields } diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 124d7625..89209b52 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -456,6 +456,55 @@ describe('assemblies create', () => { expect(await readFile(outputPath, 'utf8')).toBe('existing-bundle') }) + it('reruns single-input bundled assemblies when the input is newer than the output', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-bundle-single-input-stale-') + const inputPath = path.join(tempDir, 'a.txt') + const outputPath = path.join(tempDir, 'bundle.zip') + + await writeFile(inputPath, 'a') + await writeFile(outputPath, 'existing-bundle') + + const outputTime = new Date('2026-01-01T00:00:10.000Z') + const inputTime = new Date('2026-01-01T00:00:20.000Z') + + await utimes(inputPath, inputTime, inputTime) + await utimes(outputPath, outputTime, outputTime) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-single-input-stale' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + compressed: [{ url: 'http://downloads.test/bundle-single.zip', name: 'bundle.zip' }], + }, + }), + } + + nock('http://downloads.test').get('/bundle-single.zip').reply(200, 'fresh-bundle') + + await create(output, client as never, { + inputs: [inputPath], + output: outputPath, + singleAssembly: true, + stepsData: { + compressed: { + robot: '/file/compress', + result: true, + use: { + steps: [':original'], + bundle_steps: true, + }, + }, + }, + }) + + expect(client.createAssembly).toHaveBeenCalledTimes(1) + expect(await readFile(outputPath, 'utf8')).toBe('fresh-bundle') + }) + it('rewrites existing bundled outputs on single-assembly reruns', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index eb087c21..c648bd24 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -273,6 +273,24 @@ describe('intent commands', () => { expect(createSpy).not.toHaveBeenCalled() }) + it('rejects combining --fields labels with --for wordpress', async () => { + const { createSpy } = await runIntentCommand([ + 'image', + 'describe', + '--input', + 'hero.jpg', + '--fields', + 'labels', + '--for', + 'wordpress', + '--out', + 'fields.json', + ]) + + expect(process.exitCode).toBe(1) + expect(createSpy).not.toHaveBeenCalled() + }) + it('maps image generate flags to /image/generate step parameters', async () => { const { createSpy } = await runIntentCommand([ 'image',