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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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/69] 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', From bc4cf25147638b9135aa9f49d8f6d69fa23b6667 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 7 Apr 2026 09:39:31 +0200 Subject: [PATCH 45/69] feat(node): add markdown to pdf intent --- packages/node/package.json | 1 + packages/node/scripts/test-intents-e2e.sh | 11 + packages/node/src/cli/intentCommandSpecs.ts | 5 + packages/node/src/cli/intentRuntime.ts | 11 +- .../node/src/cli/semanticIntents/index.ts | 25 +- .../src/cli/semanticIntents/markdownPdf.ts | 245 ++++++++++++++++++ .../node/test/support/intentSmokeCases.ts | 5 + packages/node/test/unit/cli/intents.test.ts | 31 +++ yarn.lock | 10 + 9 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 packages/node/src/cli/semanticIntents/markdownPdf.ts diff --git a/packages/node/package.json b/packages/node/package.json index 6d90e59c..73f266ac 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -45,6 +45,7 @@ "is-stream": "^4.0.1", "json-to-ast": "^2.1.0", "lodash-es": "^4.17.21", + "marked": "^16.4.0", "node-watch": "^0.7.4", "p-map": "^7.0.3", "p-queue": "^9.0.1", diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index f32c69f0..abe38154 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -42,6 +42,17 @@ prepare_fixtures() { 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" + cat >"$FIXTUREDIR/input.md" <<'EOF' +# CLI Intents + +This is a **Markdown** fixture. + +## Features + +- headings render +- lists render +- emphasis renders +EOF 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" diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index 9990c61f..3e3ef1cb 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -259,6 +259,11 @@ export const intentCatalog = [ semantic: 'image-describe', paths: ['image', 'describe'], }), + defineSemanticIntent({ + kind: 'semantic', + semantic: 'markdown-pdf', + paths: ['markdown', 'pdf'], + }), defineRobotIntent({ kind: 'robot', robot: '/file/compress', diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 06433234..0e33e996 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -528,9 +528,18 @@ export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentComm rawValues: Record, preparedInputs: PreparedIntentInputs, ): Promise { + let effectivePreparedInputs = preparedInputs + const execution = this.getIntentDefinition().execution + if (execution.kind === 'dynamic-step') { + const descriptor = getSemanticIntentDescriptor(execution.handler) + if (descriptor.prepareInputs != null) { + effectivePreparedInputs = await descriptor.prepareInputs(preparedInputs, rawValues) + } + } + return await executeIntentCommand({ client: this.client, - createOptions: this.getCreateOptions(preparedInputs.inputs), + createOptions: this.getCreateOptions(effectivePreparedInputs.inputs), definition: this.getIntentDefinition(), output: this.output, outputPath: this.outputPath, diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts index 76b5adcd..37f1c7e5 100644 --- a/packages/node/src/cli/semanticIntents/index.ts +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -1,16 +1,30 @@ import type { IntentInputPolicy } from '../intentInputPolicy.ts' -import type { IntentDynamicStepExecutionDefinition, IntentRunnerKind } from '../intentRuntime.ts' +import type { + IntentDynamicStepExecutionDefinition, + IntentRunnerKind, + PreparedIntentInputs, +} from '../intentRuntime.ts' import { createImageDescribeStep, imageDescribeCommandPresentation, imageDescribeExecutionDefinition, } from './imageDescribe.ts' +import { + createMarkdownPdfStep, + markdownPdfCommandPresentation, + markdownPdfExecutionDefinition, + prepareMarkdownPdfInputs, +} from './markdownPdf.ts' export interface SemanticIntentDescriptor { createStep: (rawValues: Record) => Record execution: IntentDynamicStepExecutionDefinition inputPolicy: IntentInputPolicy outputDescription: string + prepareInputs?: ( + preparedInputs: PreparedIntentInputs, + rawValues: Record, + ) => Promise presentation: { description: string details: string @@ -28,6 +42,15 @@ export const semanticIntentDescriptors: Record presentation: imageDescribeCommandPresentation, runnerKind: 'watchable', }, + 'markdown-pdf': { + createStep: createMarkdownPdfStep, + execution: markdownPdfExecutionDefinition, + inputPolicy: { kind: 'required' }, + outputDescription: 'Write the rendered PDF to this path or directory', + prepareInputs: prepareMarkdownPdfInputs, + presentation: markdownPdfCommandPresentation, + runnerKind: 'watchable', + }, } export function getSemanticIntentDescriptor(name: string): SemanticIntentDescriptor { diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts new file mode 100644 index 00000000..6850667e --- /dev/null +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -0,0 +1,245 @@ +import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { marked } from 'marked' +import type { + IntentDynamicStepExecutionDefinition, + IntentOptionDefinition, + PreparedIntentInputs, +} from '../intentRuntime.ts' + +const defaultMarkdownFormat = 'gfm' +const defaultMarkdownTheme = 'github' + +const githubMarkdownCss = ` + :root { + color-scheme: light; + } + + body { + box-sizing: border-box; + color: #1f2328; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + margin: 0 auto; + max-width: 860px; + padding: 40px; + } + + h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.25; + margin-bottom: 16px; + margin-top: 24px; + } + + h1, h2 { + border-bottom: 1px solid #d1d9e0; + padding-bottom: 0.3em; + } + + p, ul, ol, blockquote, pre, table { + margin-bottom: 16px; + margin-top: 0; + } + + code, pre { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; + } + + code { + background: rgba(175, 184, 193, 0.2); + border-radius: 6px; + font-size: 85%; + padding: 0.2em 0.4em; + } + + pre { + background: #f6f8fa; + border-radius: 6px; + overflow: auto; + padding: 16px; + white-space: pre-wrap; + } + + pre code { + background: transparent; + padding: 0; + } + + blockquote { + border-left: 0.25em solid #d1d9e0; + color: #59636e; + padding: 0 1em; + } + + a { + color: #0969da; + text-decoration: none; + } + + table { + border-collapse: collapse; + width: 100%; + } + + table th, + table td { + border: 1px solid #d1d9e0; + padding: 6px 13px; + } +` + +export const markdownPdfExecutionDefinition = { + kind: 'dynamic-step', + handler: 'markdown-pdf', + resultStepName: 'convert', + fields: [ + { + name: 'markdownFormat', + kind: 'string', + propertyName: 'markdownFormat', + optionFlags: '--markdown-format', + description: 'Markdown variant to parse, either commonmark or gfm', + required: false, + }, + { + name: 'markdownTheme', + kind: 'string', + propertyName: 'markdownTheme', + optionFlags: '--markdown-theme', + description: 'Markdown theme to render, either github or bare', + required: false, + }, + ] as const satisfies readonly IntentOptionDefinition[], +} satisfies IntentDynamicStepExecutionDefinition + +export const markdownPdfCommandPresentation = { + description: 'Render Markdown files as PDFs', + details: + 'Renders Markdown to HTML locally, then runs `/html/convert` to produce a PDF with readable heading, list, and emphasis styling.', + examples: [ + [ + 'Render a Markdown file as a PDF', + 'transloadit markdown pdf --input README.md --out README.pdf', + ], + [ + 'Print a temporary result URL without downloading locally', + 'transloadit markdown pdf --input README.md --print-urls', + ], + ] as Array<[string, string]>, +} as const + +function resolveMarkdownFormat(value: unknown): 'commonmark' | 'gfm' { + if (value == null || value === '') { + return defaultMarkdownFormat + } + + if (value === 'commonmark' || value === 'gfm') { + return value + } + + throw new Error( + `Unsupported --markdown-format value "${String(value)}". Supported values: commonmark, gfm`, + ) +} + +function resolveMarkdownTheme(value: unknown): 'bare' | 'github' { + if (value == null || value === '') { + return defaultMarkdownTheme + } + + if (value === 'bare' || value === 'github') { + return value + } + + throw new Error( + `Unsupported --markdown-theme value "${String(value)}". Supported values: bare, github`, + ) +} + +function buildMarkdownHtml({ + html, + title, + theme, +}: { + html: string + title: string + theme: 'bare' | 'github' +}): string { + const styles = theme === 'github' ? `` : '' + + return [ + '', + '', + '', + '', + `${title}`, + styles, + '', + '', + html, + '', + '', + ].join('\n') +} + +export async function prepareMarkdownPdfInputs( + preparedInputs: PreparedIntentInputs, + rawValues: Record, +): Promise { + const markdownFormat = resolveMarkdownFormat(rawValues.markdownFormat) + const markdownTheme = resolveMarkdownTheme(rawValues.markdownTheme) + + const tempDir = await mkdtemp(path.join(tmpdir(), 'transloadit-markdown-pdf-')) + const renderedInputs = await Promise.all( + preparedInputs.inputs.map(async (inputPath, index) => { + const markdown = await readFile(inputPath, 'utf8') + const title = path.parse(inputPath).name || `markdown-${index + 1}` + const html = await marked.parse(markdown, { + async: false, + gfm: markdownFormat === 'gfm', + }) + + const renderedPath = path.join(tempDir, `${title}.html`) + await writeFile( + renderedPath, + buildMarkdownHtml({ + html, + title, + theme: markdownTheme, + }), + ) + + const inputStats = await stat(inputPath) + await utimes(renderedPath, inputStats.atime, inputStats.mtime) + return renderedPath + }), + ) + + return { + ...preparedInputs, + cleanup: [ + ...preparedInputs.cleanup, + async () => { + await rm(tempDir, { recursive: true, force: true }) + }, + ], + inputs: renderedInputs, + } +} + +export function createMarkdownPdfStep(rawValues: Record): Record { + resolveMarkdownFormat(rawValues.markdownFormat) + resolveMarkdownTheme(rawValues.markdownTheme) + + return { + robot: '/html/convert', + use: ':original', + result: true, + format: 'pdf', + fullpage: true, + wait_until: 'load', + } +} diff --git a/packages/node/test/support/intentSmokeCases.ts b/packages/node/test/support/intentSmokeCases.ts index c66b787c..d147df55 100644 --- a/packages/node/test/support/intentSmokeCases.ts +++ b/packages/node/test/support/intentSmokeCases.ts @@ -73,6 +73,11 @@ const intentSmokeOverrides: Record { ) }) + it('maps markdown pdf to /html/convert with Markdown rendering defaults', async () => { + const { createSpy } = await runIntentCommand([ + 'markdown', + 'pdf', + '--input', + 'README.md', + '--out', + 'README.pdf', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [expect.stringMatching(/README\.html$/)], + output: 'README.pdf', + stepsData: { + convert: expect.objectContaining({ + robot: '/html/convert', + use: ':original', + result: true, + format: 'pdf', + fullpage: true, + wait_until: 'load', + }), + }, + }), + ) + }) + it('downloads URL inputs for preview generate before calling assemblies create', async () => { nock('https://example.com').get('/file.pdf').reply(200, 'pdf-data') const { createSpy } = await runIntentCommand([ diff --git a/yarn.lock b/yarn.lock index 9ebd10ac..aa00783c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2563,6 +2563,7 @@ __metadata: is-stream: "npm:^4.0.1" json-to-ast: "npm:^2.1.0" lodash-es: "npm:^4.17.21" + marked: "npm:^16.4.0" minimatch: "npm:^10.1.1" nock: "npm:^14.0.10" node-watch: "npm:^0.7.4" @@ -5672,6 +5673,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^16.4.0": + version: 16.4.2 + resolution: "marked@npm:16.4.2" + bin: + marked: bin/marked.js + checksum: 10c0/fc6051142172454f2023f3d6b31cca92879ec8e1b96457086a54c70354c74b00e1b6543a76a1fad6d399366f52b90a848f6ffb8e1d65a5baff87f3ba9b8f1847 + languageName: node + linkType: hard + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" From d13623aae7e0b9396f50bf28323a2a134ee00fac Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 7 Apr 2026 11:08:01 +0200 Subject: [PATCH 46/69] fix(node): preserve markdown pdf toc anchors --- .../src/cli/semanticIntents/markdownPdf.ts | 38 ++++++++++++++++--- packages/node/test/unit/cli/intents.test.ts | 37 ++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index 6850667e..dee579ad 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' -import { marked } from 'marked' +import { Marked, Renderer } from 'marked' import type { IntentDynamicStepExecutionDefinition, IntentOptionDefinition, @@ -11,6 +11,36 @@ import type { const defaultMarkdownFormat = 'gfm' const defaultMarkdownTheme = 'github' +function slugifyHeading(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\p{Letter}\p{Number}\- _]/gu, '') + .replaceAll(' ', '-') + .replaceAll('_', '-') +} + +function createMarkdownParser(markdownFormat: 'commonmark' | 'gfm'): Marked { + const slugCounts = new Map() + const renderer = new Renderer() + + renderer.heading = function ({ depth, tokens }) { + const innerHtml = this.parser.parseInline(tokens) + const headingText = this.parser.parseInline(tokens, this.parser.textRenderer) + const baseSlug = slugifyHeading(headingText) + const duplicateCount = slugCounts.get(baseSlug) ?? 0 + slugCounts.set(baseSlug, duplicateCount + 1) + const id = duplicateCount === 0 ? baseSlug : `${baseSlug}-${duplicateCount}` + return `${innerHtml}\n` + } + + return new Marked({ + async: false, + gfm: markdownFormat === 'gfm', + renderer, + }) +} + const githubMarkdownCss = ` :root { color-scheme: light; @@ -191,16 +221,14 @@ export async function prepareMarkdownPdfInputs( ): Promise { const markdownFormat = resolveMarkdownFormat(rawValues.markdownFormat) const markdownTheme = resolveMarkdownTheme(rawValues.markdownTheme) + const markdownParser = createMarkdownParser(markdownFormat) const tempDir = await mkdtemp(path.join(tmpdir(), 'transloadit-markdown-pdf-')) const renderedInputs = await Promise.all( preparedInputs.inputs.map(async (inputPath, index) => { const markdown = await readFile(inputPath, 'utf8') const title = path.parse(inputPath).name || `markdown-${index + 1}` - const html = await marked.parse(markdown, { - async: false, - gfm: markdownFormat === 'gfm', - }) + const html = await markdownParser.parse(markdown) const renderedPath = path.join(tempDir, `${title}.html`) await writeFile( diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index ebfa77a2..6dc5ee45 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -20,6 +20,7 @@ import { } from '../../../src/cli/intentFields.ts' import { prepareIntentInputs } from '../../../src/cli/intentRuntime.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { prepareMarkdownPdfInputs } from '../../../src/cli/semanticIntents/markdownPdf.ts' import { main } from '../../../src/cli.ts' import { intentSmokeCases } from '../../support/intentSmokeCases.ts' @@ -393,6 +394,42 @@ describe('intent commands', () => { ) }) + it('renders markdown headings with ids that match toc fragment links', async () => { + const tempDir = await createTempDir('markdown-pdf-headings-') + const inputPath = path.join(tempDir, 'README.md') + await writeFile( + inputPath, + [ + '## Inhoud', + '1. [Samenvatting](#samenvatting)', + '2. [Organisatie & operatie](#organisatie--operatie)', + '', + '## Samenvatting', + '', + '## Organisatie & operatie', + ].join('\n'), + ) + + const prepared = await prepareMarkdownPdfInputs( + { + cleanup: [], + hasTransientInputs: false, + inputs: [inputPath], + }, + {}, + ) + + try { + const html = await readFile(prepared.inputs[0], 'utf8') + expect(html).toContain('href="#samenvatting"') + expect(html).toContain('id="samenvatting"') + expect(html).toContain('href="#organisatie--operatie"') + expect(html).toContain('id="organisatie--operatie"') + } finally { + await Promise.all(prepared.cleanup.map((cleanup) => cleanup())) + } + }) + it('downloads URL inputs for preview generate before calling assemblies create', async () => { nock('https://example.com').get('/file.pdf').reply(200, 'pdf-data') const { createSpy } = await runIntentCommand([ From 0c8df635c935ef71711deef2839e681a55a68754 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 7 Apr 2026 16:16:22 +0200 Subject: [PATCH 47/69] refactor(node): move markdown pdf rendering to api2 --- packages/node/package.json | 1 - .../node/src/cli/semanticIntents/index.ts | 2 - .../src/cli/semanticIntents/markdownPdf.ts | 233 ++---------------- packages/node/test/unit/cli/intents.test.ts | 72 +++--- yarn.lock | 10 - 5 files changed, 58 insertions(+), 260 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index 73f266ac..6d90e59c 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -45,7 +45,6 @@ "is-stream": "^4.0.1", "json-to-ast": "^2.1.0", "lodash-es": "^4.17.21", - "marked": "^16.4.0", "node-watch": "^0.7.4", "p-map": "^7.0.3", "p-queue": "^9.0.1", diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts index 37f1c7e5..74308acb 100644 --- a/packages/node/src/cli/semanticIntents/index.ts +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -13,7 +13,6 @@ import { createMarkdownPdfStep, markdownPdfCommandPresentation, markdownPdfExecutionDefinition, - prepareMarkdownPdfInputs, } from './markdownPdf.ts' export interface SemanticIntentDescriptor { @@ -47,7 +46,6 @@ export const semanticIntentDescriptors: Record execution: markdownPdfExecutionDefinition, inputPolicy: { kind: 'required' }, outputDescription: 'Write the rendered PDF to this path or directory', - prepareInputs: prepareMarkdownPdfInputs, presentation: markdownPdfCommandPresentation, runnerKind: 'watchable', }, diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index dee579ad..bf0f9e9b 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -1,125 +1,38 @@ -import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { Marked, Renderer } from 'marked' import type { IntentDynamicStepExecutionDefinition, IntentOptionDefinition, - PreparedIntentInputs, } from '../intentRuntime.ts' const defaultMarkdownFormat = 'gfm' const defaultMarkdownTheme = 'github' -function slugifyHeading(text: string): string { - return text - .trim() - .toLowerCase() - .replace(/[^\p{Letter}\p{Number}\- _]/gu, '') - .replaceAll(' ', '-') - .replaceAll('_', '-') -} - -function createMarkdownParser(markdownFormat: 'commonmark' | 'gfm'): Marked { - const slugCounts = new Map() - const renderer = new Renderer() - - renderer.heading = function ({ depth, tokens }) { - const innerHtml = this.parser.parseInline(tokens) - const headingText = this.parser.parseInline(tokens, this.parser.textRenderer) - const baseSlug = slugifyHeading(headingText) - const duplicateCount = slugCounts.get(baseSlug) ?? 0 - slugCounts.set(baseSlug, duplicateCount + 1) - const id = duplicateCount === 0 ? baseSlug : `${baseSlug}-${duplicateCount}` - return `${innerHtml}\n` - } - - return new Marked({ - async: false, - gfm: markdownFormat === 'gfm', - renderer, - }) -} - -const githubMarkdownCss = ` - :root { - color-scheme: light; - } - - body { - box-sizing: border-box; - color: #1f2328; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 1.6; - margin: 0 auto; - max-width: 860px; - padding: 40px; - } - - h1, h2, h3, h4, h5, h6 { - font-weight: 600; - line-height: 1.25; - margin-bottom: 16px; - margin-top: 24px; - } - - h1, h2 { - border-bottom: 1px solid #d1d9e0; - padding-bottom: 0.3em; - } - - p, ul, ol, blockquote, pre, table { - margin-bottom: 16px; - margin-top: 0; - } - - code, pre { - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; - } - - code { - background: rgba(175, 184, 193, 0.2); - border-radius: 6px; - font-size: 85%; - padding: 0.2em 0.4em; - } - - pre { - background: #f6f8fa; - border-radius: 6px; - overflow: auto; - padding: 16px; - white-space: pre-wrap; +function resolveMarkdownFormat(value: unknown): 'commonmark' | 'gfm' { + if (value == null || value === '') { + return defaultMarkdownFormat } - pre code { - background: transparent; - padding: 0; + if (value === 'commonmark' || value === 'gfm') { + return value } - blockquote { - border-left: 0.25em solid #d1d9e0; - color: #59636e; - padding: 0 1em; - } + throw new Error( + `Unsupported --markdown-format value "${String(value)}". Supported values: commonmark, gfm`, + ) +} - a { - color: #0969da; - text-decoration: none; +function resolveMarkdownTheme(value: unknown): 'bare' | 'github' { + if (value == null || value === '') { + return defaultMarkdownTheme } - table { - border-collapse: collapse; - width: 100%; + if (value === 'bare' || value === 'github') { + return value } - table th, - table td { - border: 1px solid #d1d9e0; - padding: 6px 13px; - } -` + throw new Error( + `Unsupported --markdown-theme value "${String(value)}". Supported values: bare, github`, + ) +} export const markdownPdfExecutionDefinition = { kind: 'dynamic-step', @@ -148,7 +61,7 @@ export const markdownPdfExecutionDefinition = { export const markdownPdfCommandPresentation = { description: 'Render Markdown files as PDFs', details: - 'Renders Markdown to HTML locally, then runs `/html/convert` to produce a PDF with readable heading, list, and emphasis styling.', + 'Runs `/document/convert` with `format: pdf`, letting the backend render Markdown and preserve features such as internal heading links in the generated PDF.', examples: [ [ 'Render a Markdown file as a PDF', @@ -161,113 +74,15 @@ export const markdownPdfCommandPresentation = { ] as Array<[string, string]>, } as const -function resolveMarkdownFormat(value: unknown): 'commonmark' | 'gfm' { - if (value == null || value === '') { - return defaultMarkdownFormat - } - - if (value === 'commonmark' || value === 'gfm') { - return value - } - - throw new Error( - `Unsupported --markdown-format value "${String(value)}". Supported values: commonmark, gfm`, - ) -} - -function resolveMarkdownTheme(value: unknown): 'bare' | 'github' { - if (value == null || value === '') { - return defaultMarkdownTheme - } - - if (value === 'bare' || value === 'github') { - return value - } - - throw new Error( - `Unsupported --markdown-theme value "${String(value)}". Supported values: bare, github`, - ) -} - -function buildMarkdownHtml({ - html, - title, - theme, -}: { - html: string - title: string - theme: 'bare' | 'github' -}): string { - const styles = theme === 'github' ? `` : '' - - return [ - '', - '', - '', - '', - `${title}`, - styles, - '', - '', - html, - '', - '', - ].join('\n') -} - -export async function prepareMarkdownPdfInputs( - preparedInputs: PreparedIntentInputs, - rawValues: Record, -): Promise { - const markdownFormat = resolveMarkdownFormat(rawValues.markdownFormat) - const markdownTheme = resolveMarkdownTheme(rawValues.markdownTheme) - const markdownParser = createMarkdownParser(markdownFormat) - - const tempDir = await mkdtemp(path.join(tmpdir(), 'transloadit-markdown-pdf-')) - const renderedInputs = await Promise.all( - preparedInputs.inputs.map(async (inputPath, index) => { - const markdown = await readFile(inputPath, 'utf8') - const title = path.parse(inputPath).name || `markdown-${index + 1}` - const html = await markdownParser.parse(markdown) - - const renderedPath = path.join(tempDir, `${title}.html`) - await writeFile( - renderedPath, - buildMarkdownHtml({ - html, - title, - theme: markdownTheme, - }), - ) - - const inputStats = await stat(inputPath) - await utimes(renderedPath, inputStats.atime, inputStats.mtime) - return renderedPath - }), - ) - - return { - ...preparedInputs, - cleanup: [ - ...preparedInputs.cleanup, - async () => { - await rm(tempDir, { recursive: true, force: true }) - }, - ], - inputs: renderedInputs, - } -} - export function createMarkdownPdfStep(rawValues: Record): Record { - resolveMarkdownFormat(rawValues.markdownFormat) - resolveMarkdownTheme(rawValues.markdownTheme) - return { - robot: '/html/convert', + robot: '/document/convert', use: ':original', result: true, format: 'pdf', - fullpage: true, - wait_until: 'load', + markdown_format: resolveMarkdownFormat(rawValues.markdownFormat), + markdown_theme: resolveMarkdownTheme(rawValues.markdownTheme), + // @TODO Replace this semantic CLI alias with a builtin/api2-owned command surface if we later + // want richer Markdown->PDF product semantics beyond `/document/convert format=pdf`. } } diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 6dc5ee45..f9b84b5f 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -20,7 +20,6 @@ import { } from '../../../src/cli/intentFields.ts' import { prepareIntentInputs } from '../../../src/cli/intentRuntime.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' -import { prepareMarkdownPdfInputs } from '../../../src/cli/semanticIntents/markdownPdf.ts' import { main } from '../../../src/cli.ts' import { intentSmokeCases } from '../../support/intentSmokeCases.ts' @@ -363,7 +362,7 @@ describe('intent commands', () => { ) }) - it('maps markdown pdf to /html/convert with Markdown rendering defaults', async () => { + it('maps markdown pdf to /document/convert with backend Markdown rendering defaults', async () => { const { createSpy } = await runIntentCommand([ 'markdown', 'pdf', @@ -378,56 +377,53 @@ describe('intent commands', () => { expect.any(OutputCtl), expect.anything(), expect.objectContaining({ - inputs: [expect.stringMatching(/README\.html$/)], + inputs: ['README.md'], output: 'README.pdf', stepsData: { convert: expect.objectContaining({ - robot: '/html/convert', + robot: '/document/convert', use: ':original', result: true, format: 'pdf', - fullpage: true, - wait_until: 'load', + markdown_format: 'gfm', + markdown_theme: 'github', }), }, }), ) }) - it('renders markdown headings with ids that match toc fragment links', async () => { - const tempDir = await createTempDir('markdown-pdf-headings-') - const inputPath = path.join(tempDir, 'README.md') - await writeFile( - inputPath, - [ - '## Inhoud', - '1. [Samenvatting](#samenvatting)', - '2. [Organisatie & operatie](#organisatie--operatie)', - '', - '## Samenvatting', - '', - '## Organisatie & operatie', - ].join('\n'), - ) + it('passes through explicit markdown options for backend rendering', async () => { + const { createSpy } = await runIntentCommand([ + 'markdown', + 'pdf', + '--input', + 'README.md', + '--markdown-format', + 'commonmark', + '--markdown-theme', + 'bare', + '--out', + 'README.pdf', + ]) - const prepared = await prepareMarkdownPdfInputs( - { - cleanup: [], - hasTransientInputs: false, - inputs: [inputPath], - }, - {}, + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['README.md'], + output: 'README.pdf', + stepsData: { + convert: expect.objectContaining({ + robot: '/document/convert', + format: 'pdf', + markdown_format: 'commonmark', + markdown_theme: 'bare', + }), + }, + }), ) - - try { - const html = await readFile(prepared.inputs[0], 'utf8') - expect(html).toContain('href="#samenvatting"') - expect(html).toContain('id="samenvatting"') - expect(html).toContain('href="#organisatie--operatie"') - expect(html).toContain('id="organisatie--operatie"') - } finally { - await Promise.all(prepared.cleanup.map((cleanup) => cleanup())) - } }) it('downloads URL inputs for preview generate before calling assemblies create', async () => { diff --git a/yarn.lock b/yarn.lock index aa00783c..9ebd10ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2563,7 +2563,6 @@ __metadata: is-stream: "npm:^4.0.1" json-to-ast: "npm:^2.1.0" lodash-es: "npm:^4.17.21" - marked: "npm:^16.4.0" minimatch: "npm:^10.1.1" nock: "npm:^14.0.10" node-watch: "npm:^0.7.4" @@ -5673,15 +5672,6 @@ __metadata: languageName: node linkType: hard -"marked@npm:^16.4.0": - version: 16.4.2 - resolution: "marked@npm:16.4.2" - bin: - marked: bin/marked.js - checksum: 10c0/fc6051142172454f2023f3d6b31cca92879ec8e1b96457086a54c70354c74b00e1b6543a76a1fad6d399366f52b90a848f6ffb8e1d65a5baff87f3ba9b8f1847 - languageName: node - linkType: hard - "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" From 2d51f9088933bd27a659cea1e493eb327606236d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 7 Apr 2026 16:27:59 +0200 Subject: [PATCH 48/69] fix(node): preserve upload filenames in cli assemblies --- packages/node/src/cli/commands/assemblies.ts | 45 ++++++++++++++--- .../test/unit/cli/assemblies-create.test.ts | 49 ++++++++++++++++++- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 01542755..946b0db2 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1311,11 +1311,20 @@ export async function create( const abortController = new AbortController() const outputRootIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory()) - function createAssemblyOptions(uploads?: Record): CreateAssemblyOptions { + function createAssemblyOptions({ + files, + uploads, + }: { + files?: Record + uploads?: Record + } = {}): CreateAssemblyOptions { const createOptions: CreateAssemblyOptions = { params, signal: abortController.signal, } + if (files != null && Object.keys(files).length > 0) { + createOptions.files = files + } if (uploads != null && Object.keys(uploads).length > 0) { createOptions.uploads = uploads } @@ -1412,10 +1421,21 @@ export async function create( inPath: string | null, outputPlan: OutputPlan | null, ): Promise { - const inStream = inPath ? createInputUploadStream(inPath) : null + const files = + inPath != null && inPath !== stdinWithPath.path + ? { + in: inPath, + } + : undefined + const uploads = + inPath === stdinWithPath.path + ? { + in: createInputUploadStream(inPath), + } + : undefined return await executeAssemblyLifecycle({ - createOptions: createAssemblyOptions(inStream == null ? undefined : { in: inStream }), + createOptions: createAssemblyOptions({ files, uploads }), inPath, inputPaths: inPath == null ? [] : [inPath], outputPlan, @@ -1461,28 +1481,37 @@ export async function create( return } - // Build uploads object, creating fresh streams for each file + // Preserve original basenames/extensions for filesystem uploads so the backend + // can infer types like Markdown correctly. + const files: Record = {} const uploads: Record = {} const inputPaths: string[] = [] for (const inPath of collectedPaths) { const basename = path.basename(inPath) + const collection = inPath === stdinWithPath.path ? uploads : files const key = await ensureUniqueCounterValue({ initialValue: basename, - isTaken: (candidate) => candidate in uploads, + isTaken: (candidate) => candidate in collection, nextValue: (counter) => `${path.parse(basename).name}_${counter}${path.parse(basename).ext}`, reserve: () => {}, }) - uploads[key] = createInputUploadStream(inPath) + if (inPath === stdinWithPath.path) { + uploads[key] = createInputUploadStream(inPath) + } else { + files[key] = inPath + } inputPaths.push(inPath) } - outputctl.debug(`Creating single assembly with ${Object.keys(uploads).length} files`) + outputctl.debug( + `Creating single assembly with ${Object.keys(files).length + Object.keys(uploads).length} files`, + ) try { const assembly = await queue.add(async () => { return await executeAssemblyLifecycle({ - createOptions: createAssemblyOptions(uploads), + createOptions: createAssemblyOptions({ files, uploads }), inPath: null, inputPaths, outputPlan: diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 89209b52..0b73f373 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -400,8 +400,8 @@ describe('assemblies create', () => { }) expect(client.createAssembly).toHaveBeenCalledTimes(1) - const uploads = client.createAssembly.mock.calls[0]?.[0]?.uploads - expect(Object.keys(uploads ?? {}).sort()).toEqual(['a.txt', 'b.txt']) + const files = client.createAssembly.mock.calls[0]?.[0]?.files + expect(Object.keys(files ?? {}).sort()).toEqual(['a.txt', 'b.txt']) }) it('skips bundled single-assembly runs when the output is newer than every input', async () => { @@ -502,9 +502,54 @@ describe('assemblies create', () => { }) expect(client.createAssembly).toHaveBeenCalledTimes(1) + expect(client.createAssembly.mock.calls[0]?.[0]?.files).toEqual({ + 'a.txt': inputPath, + }) expect(await readFile(outputPath, 'utf8')).toBe('fresh-bundle') }) + it('preserves the original filename for per-file uploads', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-file-upload-name-') + const inputPath = path.join(tempDir, 'README.md') + const outputPath = path.join(tempDir, 'README.pdf') + + await writeFile(inputPath, '# Hello') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-readme-md' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + converted: [{ url: 'http://downloads.test/README.pdf', name: 'README.pdf' }], + }, + }), + } + + nock('http://downloads.test').get('/README.pdf').reply(200, 'pdf-contents') + + await create(output, client as never, { + inputs: [inputPath], + output: outputPath, + stepsData: { + converted: { + robot: '/document/convert', + result: true, + use: ':original', + format: 'pdf', + }, + }, + }) + + expect(client.createAssembly).toHaveBeenCalledTimes(1) + expect(client.createAssembly.mock.calls[0]?.[0]?.files).toEqual({ + in: inputPath, + }) + expect(client.createAssembly.mock.calls[0]?.[0]?.uploads).toBeUndefined() + }) + it('rewrites existing bundled outputs on single-assembly reruns', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) From bfffbe915d121a707e65b82a2fe7f1463dea408f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 7 Apr 2026 20:09:41 +0200 Subject: [PATCH 49/69] fix(node): address council review findings --- packages/node/src/cli/commands/assemblies.ts | 22 ++++++- packages/node/src/cli/stepsInput.ts | 16 ++++- .../test/unit/cli/assemblies-create.test.ts | 60 +++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 946b0db2..d31020c7 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -547,15 +547,16 @@ async function buildDirectoryDownloadTargets({ allFiles, baseDir, groupByStep, + reservedPaths, }: { allFiles: NormalizedAssemblyResultFile[] baseDir: string groupByStep: boolean + reservedPaths: Set }): Promise { await fsp.mkdir(baseDir, { recursive: true }) const targets: AssemblyDownloadTarget[] = [] - const reservedPaths = new Set() for (const resultFile of allFiles) { const targetDir = groupByStep ? path.join(baseDir, resultFile.stepName) : baseDir await fsp.mkdir(targetDir, { recursive: true }) @@ -595,6 +596,7 @@ async function resolveResultDownloadTargets({ outputPath, outputRoot, outputRootIsDirectory, + reservedPaths, singleAssembly, }: { allFiles: NormalizedAssemblyResultFile[] @@ -606,6 +608,7 @@ async function resolveResultDownloadTargets({ outputPath: string | null outputRoot: string outputRootIsDirectory: boolean + reservedPaths: Set singleAssembly?: boolean }): Promise { const shouldGroupByInput = @@ -643,6 +646,7 @@ async function resolveResultDownloadTargets({ allFiles, baseDir: outputRoot, groupByStep: false, + reservedPaths, }) } @@ -651,6 +655,7 @@ async function resolveResultDownloadTargets({ allFiles, baseDir: resolveDirectoryBaseDir(), groupByStep: entries.length > 1, + reservedPaths, }) } @@ -662,6 +667,7 @@ async function resolveResultDownloadTargets({ allFiles, baseDir: path.join(path.dirname(outputPath), path.parse(outputPath).name), groupByStep: true, + reservedPaths, }) } @@ -735,6 +741,7 @@ async function materializeAssemblyResults({ outputRoot, outputRootIsDirectory, outputctl, + reservedPaths, results, singleAssembly, }: { @@ -747,6 +754,7 @@ async function materializeAssemblyResults({ outputRoot: string | null outputRootIsDirectory: boolean outputctl: IOutputCtl + reservedPaths: Set results: Record> singleAssembly?: boolean }): Promise { @@ -765,6 +773,7 @@ async function materializeAssemblyResults({ outputPath, outputRoot, outputRootIsDirectory, + reservedPaths, singleAssembly, }) @@ -1306,6 +1315,7 @@ export async function create( const queue = new PQueue({ concurrency }) const results: unknown[] = [] const resultUrls: ResultUrlRow[] = [] + const reservedResultPaths = new Set() let hasFailures = false // AbortController to cancel all in-flight createAssembly calls when an error occurs const abortController = new AbortController() @@ -1399,6 +1409,7 @@ export async function create( outputRoot: resolvedOutput ?? null, outputRootIsDirectory, outputctl, + reservedPaths: reservedResultPaths, results: assembly.results, singleAssembly: singleAssemblyMode, }) @@ -1451,17 +1462,21 @@ export async function create( function runSingleAssemblyEmitter(): void { const collectedPaths: string[] = [] + let inputlessOutputPlan: OutputPlan | null = null emitter.on('job', (job: Job) => { if (job.inputPath != null) { const inPath = job.inputPath outputctl.debug(`COLLECTING JOB ${inPath}`) collectedPaths.push(inPath) + return } + + inputlessOutputPlan = job.out ?? null }) emitter.on('end', async () => { - if (collectedPaths.length === 0) { + if (collectedPaths.length === 0 && inputlessOutputPlan == null) { resolve({ resultUrls, results: [], hasFailures: false }) return } @@ -1515,7 +1530,8 @@ export async function create( inPath: null, inputPaths, outputPlan: - resolvedOutput == null ? null : createOutputPlan(resolvedOutput, new Date(0)), + inputlessOutputPlan ?? + (resolvedOutput == null ? null : createOutputPlan(resolvedOutput, new Date(0))), singleAssemblyMode: true, }) }) diff --git a/packages/node/src/cli/stepsInput.ts b/packages/node/src/cli/stepsInput.ts index 392006a2..c3daf224 100644 --- a/packages/node/src/cli/stepsInput.ts +++ b/packages/node/src/cli/stepsInput.ts @@ -10,8 +10,20 @@ export function parseStepsInputJson(content: string): StepsInput { 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 + const parsedSteps = parsed as Record> + const validatedSteps = validated.data as Record> + + return Object.fromEntries( + Object.entries(parsedSteps).map(([stepName, stepInput]) => { + const normalizedStep = validatedSteps[stepName] ?? {} + return [ + stepName, + Object.fromEntries( + Object.keys(stepInput).map((key) => [key, normalizedStep[key] ?? stepInput[key]]), + ), + ] + }), + ) as StepsInput } export async function readStepsInputFile(filePath: string): Promise { diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 0b73f373..fa101065 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { create } from '../../../src/cli/commands/assemblies.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { parseStepsInputJson } from '../../../src/cli/stepsInput.ts' const tempDirs: string[] = [] @@ -316,6 +317,65 @@ describe('assemblies create', () => { expect(await readFile(outputPath, 'utf8')).toBe('bundle-contents') }) + it('runs valid inputless single-assembly steps instead of no-oping', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-inputless-single-assembly-') + const outputPath = path.join(tempDir, 'generated.png') + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-inputless-single' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + generated: [{ url: 'http://downloads.test/generated.png', name: 'generated.png' }], + }, + }), + } + + nock('http://downloads.test').get('/generated.png').reply(200, 'image-bytes') + + await create(output, client as never, { + inputs: [], + output: outputPath, + singleAssembly: true, + stepsData: { + generated: { + robot: '/image/generate', + result: true, + prompt: 'hello', + model: 'google/nano-banana', + }, + }, + }) + + expect(client.createAssembly).toHaveBeenCalledTimes(1) + expect(await readFile(outputPath, 'utf8')).toBe('image-bytes') + }) + + it('returns normalized step data from steps input parsing', () => { + const parsed = parseStepsInputJson( + JSON.stringify({ + waveform: { + robot: '/audio/waveform', + use: ':original', + result: true, + style: 1, + }, + }), + ) + + expect(parsed).toEqual({ + waveform: { + robot: '/audio/waveform', + use: ':original', + result: true, + style: 'v1', + }, + }) + }) + it('rejects invalid steps files before calling the API', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) From b56bd62876246a5e4b158d945fa6a25e7f8ba144 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 8 Apr 2026 11:06:06 +0200 Subject: [PATCH 50/69] feat(node): add markdown docx intent --- packages/node/scripts/test-intents-e2e.sh | 5 +++ packages/node/src/cli/intentCommandSpecs.ts | 5 +++ .../node/src/cli/semanticIntents/index.ts | 11 ++++++ .../src/cli/semanticIntents/markdownPdf.ts | 36 +++++++++++++++++++ .../node/test/support/intentSmokeCases.ts | 5 +++ packages/node/test/unit/cli/intents.test.ts | 31 ++++++++++++++++ 6 files changed, 93 insertions(+) diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index abe38154..434d0ffe 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -78,6 +78,10 @@ verify_pdf() { verify_file_type "$1" 'PDF document' } +verify_docx() { + verify_file_type "$1" 'Microsoft Word 2007+' +} + verify_mp3() { verify_file_type "$1" 'Audio file' } @@ -160,6 +164,7 @@ verify_output() { png) verify_png "$path" ;; jpeg) verify_jpeg "$path" ;; pdf) verify_pdf "$path" ;; + docx) verify_docx "$path" ;; mp3) verify_mp3 "$path" ;; zip) verify_zip "$path" ;; document-thumbs) verify_document_thumbs "$path" ;; diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index 3e3ef1cb..afd6f9a5 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -264,6 +264,11 @@ export const intentCatalog = [ semantic: 'markdown-pdf', paths: ['markdown', 'pdf'], }), + defineSemanticIntent({ + kind: 'semantic', + semantic: 'markdown-docx', + paths: ['markdown', 'docx'], + }), defineRobotIntent({ kind: 'robot', robot: '/file/compress', diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts index 74308acb..18a71cef 100644 --- a/packages/node/src/cli/semanticIntents/index.ts +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -10,7 +10,10 @@ import { imageDescribeExecutionDefinition, } from './imageDescribe.ts' import { + createMarkdownDocxStep, createMarkdownPdfStep, + markdownDocxCommandPresentation, + markdownDocxExecutionDefinition, markdownPdfCommandPresentation, markdownPdfExecutionDefinition, } from './markdownPdf.ts' @@ -49,6 +52,14 @@ export const semanticIntentDescriptors: Record presentation: markdownPdfCommandPresentation, runnerKind: 'watchable', }, + 'markdown-docx': { + createStep: createMarkdownDocxStep, + execution: markdownDocxExecutionDefinition, + inputPolicy: { kind: 'required' }, + outputDescription: 'Write the rendered DOCX to this path or directory', + presentation: markdownDocxCommandPresentation, + runnerKind: 'watchable', + }, } export function getSemanticIntentDescriptor(name: string): SemanticIntentDescriptor { diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index bf0f9e9b..cb391086 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -58,6 +58,11 @@ export const markdownPdfExecutionDefinition = { ] as const satisfies readonly IntentOptionDefinition[], } satisfies IntentDynamicStepExecutionDefinition +export const markdownDocxExecutionDefinition = { + ...markdownPdfExecutionDefinition, + handler: 'markdown-docx', +} satisfies IntentDynamicStepExecutionDefinition + export const markdownPdfCommandPresentation = { description: 'Render Markdown files as PDFs', details: @@ -74,6 +79,22 @@ export const markdownPdfCommandPresentation = { ] as Array<[string, string]>, } as const +export const markdownDocxCommandPresentation = { + description: 'Render Markdown files as DOCX documents', + details: + 'Runs `/document/convert` with `format: docx`, letting the backend render Markdown and convert it into a Word document.', + examples: [ + [ + 'Render a Markdown file as a DOCX file', + 'transloadit markdown docx --input README.md --out README.docx', + ], + [ + 'Print a temporary result URL without downloading locally', + 'transloadit markdown docx --input README.md --print-urls', + ], + ] as Array<[string, string]>, +} as const + export function createMarkdownPdfStep(rawValues: Record): Record { return { robot: '/document/convert', @@ -86,3 +107,18 @@ export function createMarkdownPdfStep(rawValues: Record): Recor // want richer Markdown->PDF product semantics beyond `/document/convert format=pdf`. } } + +export function createMarkdownDocxStep( + rawValues: Record, +): Record { + return { + robot: '/document/convert', + use: ':original', + result: true, + format: 'docx', + markdown_format: resolveMarkdownFormat(rawValues.markdownFormat), + markdown_theme: resolveMarkdownTheme(rawValues.markdownTheme), + // @TODO Replace this semantic CLI alias with a builtin/api2-owned command surface if we later + // want richer Markdown->DOCX product semantics beyond `/document/convert format=docx`. + } +} diff --git a/packages/node/test/support/intentSmokeCases.ts b/packages/node/test/support/intentSmokeCases.ts index d147df55..2ba4dc0f 100644 --- a/packages/node/test/support/intentSmokeCases.ts +++ b/packages/node/test/support/intentSmokeCases.ts @@ -78,6 +78,11 @@ const intentSmokeOverrides: Record { ) }) + it('maps markdown docx to /document/convert with backend Markdown rendering defaults', async () => { + const { createSpy } = await runIntentCommand([ + 'markdown', + 'docx', + '--input', + 'README.md', + '--out', + 'README.docx', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: ['README.md'], + output: 'README.docx', + stepsData: { + convert: expect.objectContaining({ + robot: '/document/convert', + use: ':original', + result: true, + format: 'docx', + markdown_format: 'gfm', + markdown_theme: 'github', + }), + }, + }), + ) + }) + it('downloads URL inputs for preview generate before calling assemblies create', async () => { nock('https://example.com').get('/file.pdf').reply(200, 'pdf-data') const { createSpy } = await runIntentCommand([ From dc6d746e3e21a4526aa963d89b1404f8f28e91b0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 8 Apr 2026 12:03:48 +0200 Subject: [PATCH 51/69] refactor(node): share markdown and result helpers --- packages/node/src/cli/commands/assemblies.ts | 36 ++-- packages/node/src/cli/resultFiles.ts | 20 +- packages/node/src/cli/resultUrls.ts | 18 +- .../node/src/cli/semanticIntents/index.ts | 6 +- .../src/cli/semanticIntents/markdownPdf.ts | 183 ++++++++++-------- 5 files changed, 153 insertions(+), 110 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index d31020c7..aafe5f44 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -35,10 +35,10 @@ 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 { NormalizedAssemblyResultFile, NormalizedAssemblyResults } from '../resultFiles.ts' +import { normalizeAssemblyResults } from '../resultFiles.ts' import type { ResultUrlRow } from '../resultUrls.ts' -import { collectResultUrlRows, printResultUrls } from '../resultUrls.ts' +import { collectNormalizedResultUrlRows, printResultUrls } from '../resultUrls.ts' import { readStepsInputFile } from '../stepsInput.ts' import { ensureError, isErrnoException } from '../types.ts' import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts' @@ -518,16 +518,6 @@ async function ensureUniquePath(targetPath: string, reservedPaths: Set): }) } -function flattenAssemblyResults(results: Record>): { - allFiles: NormalizedAssemblyResultFile[] - entries: Array<[string, Array]> -} { - return { - allFiles: flattenAssemblyResultFiles(results), - entries: Object.entries(results), - } -} - function getResultFileName(file: NormalizedAssemblyResultFile): string { return sanitizeResultName(file.name) } @@ -587,11 +577,10 @@ function getSingleResultDownloadTarget( } async function resolveResultDownloadTargets({ - allFiles, - entries, hasDirectoryInput, inPath, inputs, + normalizedResults, outputMode, outputPath, outputRoot, @@ -599,11 +588,10 @@ async function resolveResultDownloadTargets({ reservedPaths, singleAssembly, }: { - allFiles: NormalizedAssemblyResultFile[] - entries: Array<[string, Array]> hasDirectoryInput: boolean inPath: string | null inputs: string[] + normalizedResults: NormalizedAssemblyResults outputMode?: 'directory' | 'file' outputPath: string | null outputRoot: string @@ -611,6 +599,7 @@ async function resolveResultDownloadTargets({ reservedPaths: Set singleAssembly?: boolean }): Promise { + const { allFiles, entries } = normalizedResults const shouldGroupByInput = !singleAssembly && inPath != null && (hasDirectoryInput || inputs.length > 1) @@ -736,39 +725,37 @@ async function materializeAssemblyResults({ hasDirectoryInput, inPath, inputs, + normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, outputctl, reservedPaths, - results, singleAssembly, }: { abortSignal: AbortSignal hasDirectoryInput: boolean inPath: string | null inputs: string[] + normalizedResults: NormalizedAssemblyResults outputMode?: 'directory' | 'file' outputPath: string | null outputRoot: string | null outputRootIsDirectory: boolean outputctl: IOutputCtl reservedPaths: Set - results: Record> singleAssembly?: boolean }): Promise { if (outputRoot == null) { return } - const { allFiles, entries } = flattenAssemblyResults(results) const targets = await resolveResultDownloadTargets({ - allFiles, - entries, hasDirectoryInput, inPath, inputs, + normalizedResults, outputMode, outputPath, outputRoot, @@ -1383,7 +1370,8 @@ export async function create( const { assembly, assemblyId } = await awaitCompletedAssembly(createOptions) if (!assembly.results) throw new Error('No results in assembly') - resultUrls.push(...collectResultUrlRows({ assemblyId, results: assembly.results })) + const normalizedResults = normalizeAssemblyResults(assembly.results) + resultUrls.push(...collectNormalizedResultUrlRows({ assemblyId, normalizedResults })) if ( !singleAssemblyMode && @@ -1404,13 +1392,13 @@ export async function create( hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput, inPath, inputs: inputPaths, + normalizedResults, outputMode, outputPath: outputPlan?.path ?? null, outputRoot: resolvedOutput ?? null, outputRootIsDirectory, outputctl, reservedPaths: reservedResultPaths, - results: assembly.results, singleAssembly: singleAssemblyMode, }) diff --git a/packages/node/src/cli/resultFiles.ts b/packages/node/src/cli/resultFiles.ts index ed6d1a7d..73836a66 100644 --- a/packages/node/src/cli/resultFiles.ts +++ b/packages/node/src/cli/resultFiles.ts @@ -13,6 +13,11 @@ export interface NormalizedAssemblyResultFile { url: string } +export interface NormalizedAssemblyResults { + allFiles: NormalizedAssemblyResultFile[] + entries: Array<[string, Array]> +} + function isAssemblyResultEntryLike(value: unknown): value is AssemblyResultEntryLike { return value != null && typeof value === 'object' } @@ -70,13 +75,17 @@ export function normalizeAssemblyResultFile( } } -export function flattenAssemblyResultFiles(results: unknown): NormalizedAssemblyResultFile[] { +export function normalizeAssemblyResults(results: unknown): NormalizedAssemblyResults { if (results == null || typeof results !== 'object' || Array.isArray(results)) { - return [] + return { + allFiles: [], + entries: [], + } } const files: NormalizedAssemblyResultFile[] = [] - for (const [stepName, stepResults] of Object.entries(results)) { + const entries = Object.entries(results) + for (const [stepName, stepResults] of entries) { if (!Array.isArray(stepResults)) { continue } @@ -89,5 +98,8 @@ export function flattenAssemblyResultFiles(results: unknown): NormalizedAssembly } } - return files + return { + allFiles: files, + entries, + } } diff --git a/packages/node/src/cli/resultUrls.ts b/packages/node/src/cli/resultUrls.ts index b500a666..5b6a97ef 100644 --- a/packages/node/src/cli/resultUrls.ts +++ b/packages/node/src/cli/resultUrls.ts @@ -1,5 +1,6 @@ import type { IOutputCtl } from './OutputCtl.ts' -import { flattenAssemblyResultFiles } from './resultFiles.ts' +import type { NormalizedAssemblyResults } from './resultFiles.ts' +import { normalizeAssemblyResults } from './resultFiles.ts' export interface ResultUrlRow { assemblyId: string @@ -15,7 +16,20 @@ export function collectResultUrlRows({ assemblyId: string results: unknown }): ResultUrlRow[] { - return flattenAssemblyResultFiles(results).map((file) => ({ + return collectNormalizedResultUrlRows({ + assemblyId, + normalizedResults: normalizeAssemblyResults(results), + }) +} + +export function collectNormalizedResultUrlRows({ + assemblyId, + normalizedResults, +}: { + assemblyId: string + normalizedResults: NormalizedAssemblyResults +}): ResultUrlRow[] { + return normalizedResults.allFiles.map((file) => ({ assemblyId, step: file.stepName, name: file.name, diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts index 18a71cef..6d4bd855 100644 --- a/packages/node/src/cli/semanticIntents/index.ts +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -14,8 +14,10 @@ import { createMarkdownPdfStep, markdownDocxCommandPresentation, markdownDocxExecutionDefinition, + markdownDocxOutputDescription, markdownPdfCommandPresentation, markdownPdfExecutionDefinition, + markdownPdfOutputDescription, } from './markdownPdf.ts' export interface SemanticIntentDescriptor { @@ -48,7 +50,7 @@ export const semanticIntentDescriptors: Record createStep: createMarkdownPdfStep, execution: markdownPdfExecutionDefinition, inputPolicy: { kind: 'required' }, - outputDescription: 'Write the rendered PDF to this path or directory', + outputDescription: markdownPdfOutputDescription, presentation: markdownPdfCommandPresentation, runnerKind: 'watchable', }, @@ -56,7 +58,7 @@ export const semanticIntentDescriptors: Record createStep: createMarkdownDocxStep, execution: markdownDocxExecutionDefinition, inputPolicy: { kind: 'required' }, - outputDescription: 'Write the rendered DOCX to this path or directory', + outputDescription: markdownDocxOutputDescription, presentation: markdownDocxCommandPresentation, runnerKind: 'watchable', }, diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index cb391086..c82b639f 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -34,91 +34,118 @@ function resolveMarkdownTheme(value: unknown): 'bare' | 'github' { ) } -export const markdownPdfExecutionDefinition = { - kind: 'dynamic-step', - handler: 'markdown-pdf', - resultStepName: 'convert', - fields: [ - { - name: 'markdownFormat', - kind: 'string', - propertyName: 'markdownFormat', - optionFlags: '--markdown-format', - description: 'Markdown variant to parse, either commonmark or gfm', - required: false, +const markdownOptionDefinitions = [ + { + name: 'markdownFormat', + kind: 'string', + propertyName: 'markdownFormat', + optionFlags: '--markdown-format', + description: 'Markdown variant to parse, either commonmark or gfm', + required: false, + }, + { + name: 'markdownTheme', + kind: 'string', + propertyName: 'markdownTheme', + optionFlags: '--markdown-theme', + description: 'Markdown theme to render, either github or bare', + required: false, + }, +] as const satisfies readonly IntentOptionDefinition[] + +interface MarkdownConvertSemanticIntentDefinition { + createStep: (rawValues: Record) => Record + execution: IntentDynamicStepExecutionDefinition + outputDescription: string + presentation: { + description: string + details: string + examples: Array<[string, string]> + } +} + +function createMarkdownConvertSemanticIntent({ + description, + details, + exampleOutput, + format, + handler, +}: { + description: string + details: string + exampleOutput: string + format: 'docx' | 'pdf' + handler: 'markdown-docx' | 'markdown-pdf' +}): MarkdownConvertSemanticIntentDefinition { + const formatLabel = format.toUpperCase() + + return { + createStep(rawValues) { + return { + robot: '/document/convert', + use: ':original', + result: true, + format, + markdown_format: resolveMarkdownFormat(rawValues.markdownFormat), + markdown_theme: resolveMarkdownTheme(rawValues.markdownTheme), + // @TODO Replace this semantic CLI alias with a builtin/api2-owned command surface if we later + // want richer Markdown conversion semantics beyond `/document/convert`. + } }, - { - name: 'markdownTheme', - kind: 'string', - propertyName: 'markdownTheme', - optionFlags: '--markdown-theme', - description: 'Markdown theme to render, either github or bare', - required: false, + execution: { + kind: 'dynamic-step', + handler, + resultStepName: 'convert', + fields: markdownOptionDefinitions, }, - ] as const satisfies readonly IntentOptionDefinition[], -} satisfies IntentDynamicStepExecutionDefinition - -export const markdownDocxExecutionDefinition = { - ...markdownPdfExecutionDefinition, - handler: 'markdown-docx', -} satisfies IntentDynamicStepExecutionDefinition + outputDescription: `Write the rendered ${formatLabel} to this path or directory`, + presentation: { + description, + details, + examples: [ + [ + `Render a Markdown file as a ${formatLabel} file`, + `transloadit markdown ${format} --input README.md --out ${exampleOutput}`, + ], + [ + 'Print a temporary result URL without downloading locally', + `transloadit markdown ${format} --input README.md --print-urls`, + ], + ], + }, + } +} -export const markdownPdfCommandPresentation = { +export const markdownPdfSemanticIntent = createMarkdownConvertSemanticIntent({ description: 'Render Markdown files as PDFs', details: 'Runs `/document/convert` with `format: pdf`, letting the backend render Markdown and preserve features such as internal heading links in the generated PDF.', - examples: [ - [ - 'Render a Markdown file as a PDF', - 'transloadit markdown pdf --input README.md --out README.pdf', - ], - [ - 'Print a temporary result URL without downloading locally', - 'transloadit markdown pdf --input README.md --print-urls', - ], - ] as Array<[string, string]>, -} as const - -export const markdownDocxCommandPresentation = { + exampleOutput: 'README.pdf', + format: 'pdf', + handler: 'markdown-pdf', +}) + +export const markdownDocxSemanticIntent = createMarkdownConvertSemanticIntent({ description: 'Render Markdown files as DOCX documents', details: 'Runs `/document/convert` with `format: docx`, letting the backend render Markdown and convert it into a Word document.', - examples: [ - [ - 'Render a Markdown file as a DOCX file', - 'transloadit markdown docx --input README.md --out README.docx', - ], - [ - 'Print a temporary result URL without downloading locally', - 'transloadit markdown docx --input README.md --print-urls', - ], - ] as Array<[string, string]>, -} as const - -export function createMarkdownPdfStep(rawValues: Record): Record { - return { - robot: '/document/convert', - use: ':original', - result: true, - format: 'pdf', - markdown_format: resolveMarkdownFormat(rawValues.markdownFormat), - markdown_theme: resolveMarkdownTheme(rawValues.markdownTheme), - // @TODO Replace this semantic CLI alias with a builtin/api2-owned command surface if we later - // want richer Markdown->PDF product semantics beyond `/document/convert format=pdf`. - } -} + exampleOutput: 'README.docx', + format: 'docx', + handler: 'markdown-docx', +}) -export function createMarkdownDocxStep( - rawValues: Record, -): Record { - return { - robot: '/document/convert', - use: ':original', - result: true, - format: 'docx', - markdown_format: resolveMarkdownFormat(rawValues.markdownFormat), - markdown_theme: resolveMarkdownTheme(rawValues.markdownTheme), - // @TODO Replace this semantic CLI alias with a builtin/api2-owned command surface if we later - // want richer Markdown->DOCX product semantics beyond `/document/convert format=docx`. - } -} +export const markdownPdfExecutionDefinition = markdownPdfSemanticIntent.execution + +export const markdownDocxExecutionDefinition = markdownDocxSemanticIntent.execution + +export const markdownPdfCommandPresentation = markdownPdfSemanticIntent.presentation + +export const markdownDocxCommandPresentation = markdownDocxSemanticIntent.presentation + +export const createMarkdownPdfStep = markdownPdfSemanticIntent.createStep + +export const createMarkdownDocxStep = markdownDocxSemanticIntent.createStep + +export const markdownPdfOutputDescription = markdownPdfSemanticIntent.outputDescription + +export const markdownDocxOutputDescription = markdownDocxSemanticIntent.outputDescription From 4e3d106ea69179758da3c0770d211a9fca0790cf Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 8 Apr 2026 13:09:27 +0200 Subject: [PATCH 52/69] fix(node): tighten intent and input safeguards --- packages/node/src/cli/commands/assemblies.ts | 2 + packages/node/src/cli/intentRuntime.ts | 11 ++- packages/node/src/ensureUniqueCounter.ts | 55 +++++++++++++- packages/node/src/inputFiles.ts | 50 +++++++++++-- packages/node/test/unit/cli/intents.test.ts | 73 +++++++++++++++++++ .../test/unit/ensure-unique-counter.test.ts | 32 ++++++++ packages/node/test/unit/input-files.test.ts | 32 ++++++++ 7 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 packages/node/test/unit/ensure-unique-counter.test.ts diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index aafe5f44..7a028933 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -515,6 +515,7 @@ async function ensureUniquePath(targetPath: string, reservedPaths: Set): reservedPaths.add(candidate) }, nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`), + scope: reservedPaths, }) } @@ -1498,6 +1499,7 @@ export async function create( nextValue: (counter) => `${path.parse(basename).name}_${counter}${path.parse(basename).ext}`, reserve: () => {}, + scope: collection, }) if (inPath === stdinWithPath.path) { uploads[key] = createInputUploadStream(inPath) diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 0e33e996..898c4951 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -594,7 +594,7 @@ export abstract class GeneratedWatchableFileIntentCommand extends GeneratedFileI const sharedValidationError = validateSharedFileProcessingOptions({ explicitInputCount: this.getProvidedInputCount(), - singleAssembly: false, + singleAssembly: this.getSingleAssemblyEnabled(), watch: this.watch, watchRequiresInputsMessage: `${this.getIntentDefinition().commandLabel} --watch requires --input or --input-base64`, }) @@ -611,6 +611,10 @@ export abstract class GeneratedWatchableFileIntentCommand extends GeneratedFileI return undefined } + protected getSingleAssemblyEnabled(): boolean { + return false + } + protected override validatePreparedInputs( preparedInputs: PreparedIntentInputs, ): number | undefined { @@ -625,6 +629,10 @@ export abstract class GeneratedWatchableFileIntentCommand extends GeneratedFileI export abstract class GeneratedStandardFileIntentCommand extends GeneratedWatchableFileIntentCommand { singleAssembly = singleAssemblyOption() + protected override getSingleAssemblyEnabled(): boolean { + return this.singleAssembly + } + protected override getCreateOptions( inputs: string[], ): Omit { @@ -645,6 +653,7 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedWatcha if ( this.singleAssembly && this.getProvidedInputCount() > 1 && + this.outputPath != null && !this.isDirectoryOutputTarget() ) { this.output.error( diff --git a/packages/node/src/ensureUniqueCounter.ts b/packages/node/src/ensureUniqueCounter.ts index 43ff4f7b..baf1ffbf 100644 --- a/packages/node/src/ensureUniqueCounter.ts +++ b/packages/node/src/ensureUniqueCounter.ts @@ -1,4 +1,6 @@ -export async function ensureUniqueCounterValue({ +const uniqueCounterScopes = new WeakMap>() + +async function runEnsureUniqueCounterValue({ initialValue, isTaken, reserve, @@ -20,3 +22,54 @@ export async function ensureUniqueCounterValue({ reserve(candidate) return candidate } + +export async function ensureUniqueCounterValue({ + initialValue, + isTaken, + reserve, + nextValue, + scope, +}: { + initialValue: T + isTaken: (candidate: T) => Promise | boolean + reserve: (candidate: T) => void + nextValue: (counter: number) => T + scope?: object +}): Promise { + if (scope == null) { + return await runEnsureUniqueCounterValue({ + initialValue, + isTaken, + reserve, + nextValue, + }) + } + + const previous = uniqueCounterScopes.get(scope) ?? Promise.resolve() + let releaseScope: (() => void) | undefined + const pendingScope = new Promise((resolve) => { + releaseScope = resolve + }) + const currentScope = previous + .catch(() => undefined) + .then(async () => { + await pendingScope + }) + uniqueCounterScopes.set(scope, currentScope) + + await previous.catch(() => undefined) + + try { + return await runEnsureUniqueCounterValue({ + initialValue, + isTaken, + reserve, + nextValue, + }) + } finally { + releaseScope?.() + if (uniqueCounterScopes.get(scope) === currentScope) { + uniqueCounterScopes.delete(scope) + } + } +} diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index fd635aff..cc659ecf 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -73,6 +73,7 @@ const ensureUniqueStepName = async (baseName: string, used: Set): Promis isTaken: (candidate) => used.has(candidate), reserve: (candidate) => used.add(candidate), nextValue: (counter) => `${baseName}_${counter}`, + scope: used, }) const ensureUniqueTempFilePath = async ( @@ -88,6 +89,7 @@ const ensureUniqueTempFilePath = async ( isTaken: (candidate) => used.has(candidate), reserve: (candidate) => used.add(candidate), nextValue: (counter) => join(root, `${stem}-${counter}${extension}`), + scope: used, }) } @@ -132,11 +134,43 @@ const isRedirectStatusCode = (statusCode: number): boolean => statusCode === 307 || statusCode === 308 +const ipv4FromMappedIpv6 = (address: string): string | null => { + const lowerAddress = address.toLowerCase() + const mappedPrefix = lowerAddress.startsWith('::ffff:') + ? '::ffff:' + : lowerAddress.startsWith('0:0:0:0:0:ffff:') + ? '0:0:0:0:0:ffff:' + : null + + if (mappedPrefix == null) { + return null + } + + const mappedValue = lowerAddress.slice(mappedPrefix.length) + if (mappedValue.includes('.')) { + return mappedValue + } + + const segments = mappedValue.split(':') + if (segments.length !== 2) { + return null + } + + const values = segments.map((segment) => Number.parseInt(segment, 16)) + if (values.some((value) => Number.isNaN(value) || value < 0 || value > 0xffff)) { + return null + } + + return values.flatMap((value) => [(value >> 8) & 0xff, value & 0xff]).join('.') +} + const isPrivateIp = (address: string): boolean => { - if (address === 'localhost') return true - const family = isIP(address) + const normalizedAddress = + address.startsWith('[') && address.endsWith(']') ? address.slice(1, -1) : address + if (normalizedAddress === 'localhost') return true + const family = isIP(normalizedAddress) if (family === 4) { - const parts = address.split('.').map((chunk) => Number(chunk)) + const parts = normalizedAddress.split('.').map((chunk) => Number(chunk)) const [a, b] = parts if (a === 10) return true if (a === 127) return true @@ -147,8 +181,14 @@ const isPrivateIp = (address: string): boolean => { return false } if (family === 6) { - const normalized = address.toLowerCase() - if (normalized === '::1') return true + const normalized = + normalizedAddress.toLowerCase().split('%')[0] ?? normalizedAddress.toLowerCase() + if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true + if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true + const mappedAddress = ipv4FromMappedIpv6(normalized) + if (mappedAddress != null && isPrivateIp(mappedAddress)) { + return true + } if (normalized.startsWith('fe80:')) return true if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true return false diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index b9fd6d62..29a432d8 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -642,6 +642,79 @@ describe('intent commands', () => { ) }) + it('allows multi-input standard single-assembly runs with --print-urls and no --out', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const tempDir = await createTempDir('transloadit-intent-single-assembly-urls-') + 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, + resultUrls: [], + }) + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'optimize', + '--single-assembly', + '--input', + inputA, + '--input', + inputB, + '--print-urls', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [inputA, inputB], + output: null, + singleAssembly: true, + }), + ) + }) + + it('rejects combining --watch with --single-assembly before processing', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + + const tempDir = await createTempDir('transloadit-intent-watch-single-assembly-') + const inputPath = path.join(tempDir, 'input.jpg') + await writeFile(inputPath, 'a') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + resultUrls: [], + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'optimize', + '--input', + inputPath, + '--out', + path.join(tempDir, 'optimized.jpg'), + '--watch', + '--single-assembly', + ]) + + expect(process.exitCode).toBe(1) + expect(createSpy).not.toHaveBeenCalled() + const loggedError = errorSpy.mock.calls.flatMap((call) => call.map(String)).join(' ') + expect(loggedError).toContain('--single-assembly cannot be used with --watch') + }) + it('maps video encode-hls to the builtin template', async () => { const { createSpy } = await runIntentCommand([ 'video', diff --git a/packages/node/test/unit/ensure-unique-counter.test.ts b/packages/node/test/unit/ensure-unique-counter.test.ts new file mode 100644 index 00000000..28c4ff31 --- /dev/null +++ b/packages/node/test/unit/ensure-unique-counter.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { ensureUniqueCounterValue } from '../../src/ensureUniqueCounter.ts' + +describe('ensureUniqueCounterValue', () => { + it('does not hand out the same candidate to concurrent callers in the same scope', async () => { + const reserved = new Set() + const seenCandidates: string[] = [] + + const allocate = async (): Promise => + await ensureUniqueCounterValue({ + initialValue: 'result.txt', + isTaken: async (candidate) => { + seenCandidates.push(candidate) + await Promise.resolve() + return reserved.has(candidate) + }, + reserve: (candidate) => { + reserved.add(candidate) + }, + nextValue: (counter) => `result__${counter}.txt`, + scope: reserved, + }) + + const [first, second] = await Promise.all([allocate(), allocate()]) + + expect(new Set([first, second]).size).toBe(2) + expect(reserved).toEqual(new Set([first, second])) + expect(seenCandidates.filter((candidate) => candidate === 'result.txt').length).toBeGreaterThan( + 0, + ) + }) +}) diff --git a/packages/node/test/unit/input-files.test.ts b/packages/node/test/unit/input-files.test.ts index a498fc45..b980f0a6 100644 --- a/packages/node/test/unit/input-files.test.ts +++ b/packages/node/test/unit/input-files.test.ts @@ -109,6 +109,38 @@ describe('prepareInputFiles', () => { ).rejects.toThrow('URL downloads are limited') }) + it('rejects non-canonical IPv6 loopback URL downloads', async () => { + await expect( + prepareInputFiles({ + inputFiles: [ + { + kind: 'url', + field: 'remote', + url: 'http://[0:0:0:0:0:0:0:1]/secret', + }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }), + ).rejects.toThrow('URL downloads are limited') + }) + + it('rejects IPv4-mapped loopback URL downloads', async () => { + await expect( + prepareInputFiles({ + inputFiles: [ + { + kind: 'url', + field: 'remote', + url: 'http://[::ffff:127.0.0.1]/secret', + }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }), + ).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') From 9acc76597bd5ad46392841a36d0f46f8fda577f1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 8 Apr 2026 13:26:04 +0200 Subject: [PATCH 53/69] fix(node): preserve hidden temp input filenames --- packages/node/src/inputFiles.ts | 10 +++--- packages/node/test/unit/input-files.test.ts | 36 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index cc659ecf..d60bc334 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -3,7 +3,7 @@ import { createWriteStream } from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { isIP } from 'node:net' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, parse } from 'node:path' import type { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import type CacheableLookup from 'cacheable-lookup' @@ -81,14 +81,12 @@ const ensureUniqueTempFilePath = async ( 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) + const parsedFilename = parse(basename(filename)) return await ensureUniqueCounterValue({ - initialValue: join(root, parsed), + initialValue: join(root, parsedFilename.base), isTaken: (candidate) => used.has(candidate), reserve: (candidate) => used.add(candidate), - nextValue: (counter) => join(root, `${stem}-${counter}${extension}`), + nextValue: (counter) => join(root, `${parsedFilename.name}-${counter}${parsedFilename.ext}`), scope: used, }) } diff --git a/packages/node/test/unit/input-files.test.ts b/packages/node/test/unit/input-files.test.ts index b980f0a6..56a8f04a 100644 --- a/packages/node/test/unit/input-files.test.ts +++ b/packages/node/test/unit/input-files.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' import { prepareInputFiles } from '../../src/inputFiles.ts' @@ -75,6 +75,40 @@ describe('prepareInputFiles', () => { } }) + it('preserves leading-dot basenames when duplicate tempfiles collide', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'transloadit-test-')) + + try { + const base64 = Buffer.from('hello').toString('base64') + + const result = await prepareInputFiles({ + inputFiles: [ + { + kind: 'base64', + field: 'first', + base64, + filename: '.gitignore', + }, + { + kind: 'base64', + field: 'second', + base64, + filename: '.gitignore', + }, + ], + base64Strategy: 'tempfile', + tempDir, + }) + + expect(result.files.first.startsWith(tempDir)).toBe(true) + expect(result.files.second.startsWith(tempDir)).toBe(true) + expect(basename(result.files.first)).toBe('.gitignore') + expect(basename(result.files.second)).toBe('.gitignore-1') + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) + it('rejects oversized base64 payloads before decoding', async () => { const oversized = '!'.repeat(128) From fc0e7b799a1c587c112e38e7650fd99775a86100 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 8 Apr 2026 13:47:20 +0200 Subject: [PATCH 54/69] fix(node): tighten URL and stale result handling --- packages/node/src/cli/commands/assemblies.ts | 3 +- packages/node/src/cli/intentRuntime.ts | 40 ++++++- packages/node/src/inputFiles.ts | 3 + .../test/unit/cli/assemblies-create.test.ts | 106 ++++++++++++++++++ packages/node/test/unit/cli/intents.test.ts | 61 +++++++++- packages/node/test/unit/input-files.test.ts | 36 ++++++ 6 files changed, 245 insertions(+), 4 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 7a028933..b005a3cd 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -1372,7 +1372,6 @@ export async function create( const { assembly, assemblyId } = await awaitCompletedAssembly(createOptions) if (!assembly.results) throw new Error('No results in assembly') const normalizedResults = normalizeAssemblyResults(assembly.results) - resultUrls.push(...collectNormalizedResultUrlRows({ assemblyId, normalizedResults })) if ( !singleAssemblyMode && @@ -1388,6 +1387,8 @@ export async function create( return assembly } + resultUrls.push(...collectNormalizedResultUrlRows({ assemblyId, normalizedResults })) + await materializeAssemblyResults({ abortSignal: abortController.signal, hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput, diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 898c4951..2930a3e1 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -108,6 +108,35 @@ function normalizeBase64Value(value: string): string { return trimmed.slice(markerIndex + marker.length) } +function inferFilenameFromBase64Value(value: string, index: number): string { + const trimmed = value.trim() + const marker = ';base64,' + const markerIndex = trimmed.indexOf(marker) + if (!trimmed.startsWith('data:') || markerIndex === -1) { + return `input-base64-${index}.bin` + } + + const mediaType = trimmed.slice('data:'.length, markerIndex).split(';')[0]?.toLowerCase() ?? '' + const extension = + mediaType === 'text/plain' + ? 'txt' + : mediaType === 'text/markdown' + ? 'md' + : mediaType === 'application/pdf' + ? 'pdf' + : mediaType === 'image/png' + ? 'png' + : mediaType === 'image/jpeg' + ? 'jpg' + : mediaType === 'image/webp' + ? 'webp' + : mediaType === 'application/json' + ? 'json' + : 'bin' + + return `input-base64-${index}.${extension}` +} + export async function prepareIntentInputs({ inputBase64Values, inputValues, @@ -147,7 +176,7 @@ export async function prepareIntentInputs({ for (const [index, value] of inputBase64Values.entries()) { const field = `input_base64_${index + 1}` - const filename = `input-base64-${index + 1}.bin` + const filename = inferFilenameFromBase64Value(value, index + 1) syntheticInputs.push({ kind: 'base64', field, @@ -652,7 +681,14 @@ export abstract class GeneratedStandardFileIntentCommand extends GeneratedWatcha if ( this.singleAssembly && - this.getProvidedInputCount() > 1 && + (this.getProvidedInputCount() > 1 || + this.inputs.some((inputPath) => { + try { + return statSync(inputPath).isDirectory() + } catch { + return false + } + })) && this.outputPath != null && !this.isDirectoryOutputTarget() ) { diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index d60bc334..27c9a710 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -173,9 +173,12 @@ const isPrivateIp = (address: string): boolean => { if (a === 10) return true if (a === 127) return true if (a === 0) return true + if (a === 100 && b >= 64 && b <= 127) return true if (a === 169 && b === 254) return true if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 0 && parts[2] === 0) return true if (a === 192 && b === 168) return true + if (a === 198 && (b === 18 || b === 19)) return true return false } if (family === 6) { diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index fa101065..9ec70c6c 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -760,6 +760,112 @@ describe('assemblies create', () => { expect(await readFile(outputPath, 'utf8')).toBe('new-result') }) + it('does not return stale watched result URLs that lose the race', 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-urls-') + 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, + resultUrls: [ + { + assemblyId: 'assembly-new', + step: 'thumbs', + name: 'new.jpg', + url: 'http://downloads.test/new.jpg', + }, + ], + }), + ) + }) + it('does not try to delete /dev/stdin after stdin processing', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) 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 29a432d8..abbe4a9b 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import nock from 'nock' @@ -549,6 +549,29 @@ describe('intent commands', () => { ) }) + it('preserves data URL media-type filenames for base64 intent inputs', async () => { + const base64Value = `data:text/plain;base64,${Buffer.from('hello').toString('base64')}` + + const { createSpy } = await runIntentCommand([ + 'document', + 'convert', + '--input-base64', + base64Value, + '--format', + 'pdf', + '--print-urls', + ]) + + expect(process.exitCode).toBeUndefined() + expect(createSpy).toHaveBeenCalledWith( + expect.any(OutputCtl), + expect.anything(), + expect.objectContaining({ + inputs: [expect.stringMatching(/input-base64-1\.(txt|text)$/)], + }), + ) + }) + it('rejects --watch URL inputs before downloading them', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') @@ -715,6 +738,42 @@ describe('intent commands', () => { expect(loggedError).toContain('--single-assembly cannot be used with --watch') }) + it('rejects single-directory 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-dir-') + const inputDir = path.join(tempDir, 'inputs') + await mkdir(inputDir, { recursive: true }) + await writeFile(path.join(inputDir, 'a.jpg'), 'a') + await writeFile(path.join(inputDir, 'b.jpg'), 'b') + + const createSpy = vi.spyOn(assembliesCommands, 'create').mockResolvedValue({ + results: [], + hasFailures: false, + resultUrls: [], + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stdout, 'write').mockImplementation(noopWrite) + + await main([ + 'image', + 'optimize', + '--single-assembly', + '--input', + inputDir, + '--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 () => { const { createSpy } = await runIntentCommand([ 'video', diff --git a/packages/node/test/unit/input-files.test.ts b/packages/node/test/unit/input-files.test.ts index 56a8f04a..02f9102c 100644 --- a/packages/node/test/unit/input-files.test.ts +++ b/packages/node/test/unit/input-files.test.ts @@ -196,6 +196,42 @@ describe('prepareInputFiles', () => { expect(downloadScope.isDone()).toBe(false) }) + it('rejects hostnames that resolve to carrier-grade NAT ranges', async () => { + lookupMock.mockResolvedValue([{ address: '100.64.0.1', family: 4 }]) + + await expect( + prepareInputFiles({ + inputFiles: [ + { + kind: 'url', + field: 'remote', + url: 'http://cgnat.test/secret', + }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }), + ).rejects.toThrow('URL downloads are limited') + }) + + it('rejects hostnames that resolve to benchmark-testing ranges', async () => { + lookupMock.mockResolvedValue([{ address: '198.18.0.1', family: 4 }]) + + await expect( + prepareInputFiles({ + inputFiles: [ + { + kind: 'url', + field: 'remote', + url: 'http://benchmark.test/secret', + }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }), + ).rejects.toThrow('URL downloads are limited') + }) + 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') From 8b8b4d68b7b3c163568193367866c41eb65d1741 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 8 Apr 2026 14:44:42 +0200 Subject: [PATCH 55/69] fix(node): support public IPv6 URL inputs --- packages/node/src/inputFiles.ts | 195 ++++++++++++-------- packages/node/test/unit/input-files.test.ts | 49 ++++- 2 files changed, 166 insertions(+), 78 deletions(-) diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index 27c9a710..c63fc5c1 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -197,34 +197,137 @@ const isPrivateIp = (address: string): boolean => { return false } -const resolvePublicDownloadAddress = async ( +export const resolvePublicDownloadAddresses = async ( value: string, -): Promise<{ address: string; family: 4 | 6 }> => { +): Promise> => { const parsed = new URL(value) + const hostname = + parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']') + ? parsed.hostname.slice(1, -1) + : parsed.hostname if (!['http:', 'https:'].includes(parsed.protocol)) { throw new Error(`URL downloads are limited to http/https: ${value}`) } - if (isPrivateIp(parsed.hostname)) { + if (isPrivateIp(hostname)) { throw new Error(`URL downloads are limited to public hosts: ${value}`) } - const resolvedAddresses = await dnsPromises.lookup(parsed.hostname, { - all: true, - verbatim: true, - }) + const literalFamily = isIP(hostname) + const resolvedAddresses = + literalFamily !== 0 + ? [{ address: hostname, family: literalFamily as 4 | 6 }] + : await dnsPromises.lookup(hostname, { + all: true, + verbatim: true, + }) 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) { + if (resolvedAddresses.length === 0) { throw new Error(`Unable to resolve URL hostname: ${value}`) } - return { - address: firstAddress.address, - family: firstAddress.family as 4 | 6, + return resolvedAddresses.map((address) => ({ + address: address.address, + family: address.family as 4 | 6, + })) +} + +export function createPinnedDnsLookup( + validatedAddresses: Array<{ address: string; family: 4 | 6 }>, +): CacheableLookup['lookup'] { + const pinnedAddresses = [...validatedAddresses] + + function pickAddress(family?: IPFamily): { address: string; family: 4 | 6 } | null { + if (family == null) { + return pinnedAddresses[0] ?? null + } + + return pinnedAddresses.find((address) => address.family === family) ?? null } + + 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') { + const address = pickAddress() + if (address == null) { + familyOrCallback( + new Error('No validated addresses available') as NodeJS.ErrnoException, + '', + 4, + ) + return + } + familyOrCallback(null, address.address, address.family) + return + } + + if ( + typeof familyOrCallback === 'object' && + familyOrCallback != null && + 'all' in familyOrCallback + ) { + ;( + callback as ( + error: NodeJS.ErrnoException | null, + result: ReadonlyArray, + ) => void + )( + null, + pinnedAddresses.map((address) => ({ + address: address.address, + family: address.family, + expires: 0, + })), + ) + return + } + + const family = typeof familyOrCallback === 'number' ? familyOrCallback : undefined + const address = pickAddress(family) + if (address == null) { + ;( + callback as (error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void + )(new Error('No validated addresses available') as NodeJS.ErrnoException, '', family ?? 4) + return + } + + ;(callback as (error: NodeJS.ErrnoException | null, address: string, family: IPFamily) => void)( + null, + address.address, + address.family, + ) + } + + return pinnedDnsLookup } const downloadUrlToFile = async ({ @@ -239,13 +342,13 @@ 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 + let validatedAddresses: Array<{ address: string; family: 4 | 6 }> | null = null if (!allowPrivateUrls) { - validatedAddress = await resolvePublicDownloadAddress(currentUrl) + validatedAddresses = await resolvePublicDownloadAddresses(currentUrl) } const dnsLookup: CacheableLookup['lookup'] | undefined = - validatedAddress == null ? undefined : createPinnedDnsLookup(validatedAddress) + validatedAddresses == null ? undefined : createPinnedDnsLookup(validatedAddresses) const responseStream = got.stream(currentUrl, { dnsLookup, @@ -291,68 +394,6 @@ 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/input-files.test.ts b/packages/node/test/unit/input-files.test.ts index 02f9102c..afacfca6 100644 --- a/packages/node/test/unit/input-files.test.ts +++ b/packages/node/test/unit/input-files.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import nock from 'nock' import { afterEach, describe, expect, it, vi } from 'vitest' -import { prepareInputFiles } from '../../src/inputFiles.ts' +import { + createPinnedDnsLookup, + prepareInputFiles, + resolvePublicDownloadAddresses, +} from '../../src/inputFiles.ts' const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn(), @@ -257,6 +261,13 @@ describe('prepareInputFiles', () => { expect(privateScope.isDone()).toBe(false) }) + it('allows public IPv6 literal URL downloads under the private-host guard', async () => { + const resolved = await resolvePublicDownloadAddresses('http://[2001:db8::1]/public') + + expect(resolved).toEqual([{ address: '2001:db8::1', family: 6 }]) + expect(lookupMock).not.toHaveBeenCalled() + }) + 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') @@ -281,4 +292,40 @@ describe('prepareInputFiles', () => { await Promise.all(result.cleanup.map((cleanup) => cleanup())) } }) + + it('returns all validated public addresses from the pinned lookup and honors requested families', async () => { + const lookup = createPinnedDnsLookup([ + { address: '2001:db8::1', family: 6 }, + { address: '198.51.100.10', family: 4 }, + ]) + + const allAddresses = await new Promise>( + (resolve, reject) => { + lookup('rebind.test', { all: true }, (error, result) => { + if (error != null) { + reject(error) + return + } + resolve(result) + }) + }, + ) + const ipv4Address = await new Promise<{ address: string; family: number }>( + (resolve, reject) => { + lookup('rebind.test', 4, (error, address, family) => { + if (error != null) { + reject(error) + return + } + resolve({ address, family }) + }) + }, + ) + + expect(allAddresses).toEqual([ + { address: '2001:db8::1', family: 6, expires: 0 }, + { address: '198.51.100.10', family: 4, expires: 0 }, + ]) + expect(ipv4Address).toEqual({ address: '198.51.100.10', family: 4 }) + }) }) From 1c8f14bd14894b3e39e99ec09efac97b8d42caa2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 8 Apr 2026 15:04:58 +0200 Subject: [PATCH 56/69] docs(node): generate intent command reference --- packages/node/README.md | 1104 ++++++++++++++++++- packages/node/docs/intent-commands.md | 1094 ++++++++++++++++++ packages/node/package.json | 3 +- packages/node/src/cli/generateIntentDocs.ts | 401 +++++++ packages/node/src/cli/intentCommands.ts | 13 +- 5 files changed, 2601 insertions(+), 14 deletions(-) create mode 100644 packages/node/docs/intent-commands.md create mode 100644 packages/node/src/cli/generateIntentDocs.ts diff --git a/packages/node/README.md b/packages/node/README.md index d84c3443..aeb2eab5 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -86,24 +86,1102 @@ npx -y transloadit auth token --aud mcp --scope assemblies:write,templates:read For common one-off tasks, prefer the intent-first commands: +The full generated intent reference also lives in [`docs/intent-commands.md`](./docs/intent-commands.md). + + + +#### At a glance + +Intent commands are the fastest path to common one-off tasks from the CLI. +Use `--print-urls` when you want temporary result URLs without downloading locally. +All intent commands also support the global CLI flags `--json`, `--log-level`, `--endpoint`, and `--help`. + +| Command | What it does | Input | Output | +| --- | --- | --- | --- | +| `image generate` | Generate images from text prompts | none | file | +| `preview generate` | Generate a preview thumbnail | files, directories, URLs, base64 | file | +| `image remove-background` | Remove the background from images | files, directories, URLs, base64 | file | +| `image optimize` | Optimize images without quality loss | files, directories, URLs, base64 | file | +| `image resize` | Convert, resize, or watermark images | files, directories, URLs, base64 | file | +| `document convert` | Convert documents into different formats | files, directories, URLs, base64 | file | +| `document optimize` | Reduce PDF file size | files, directories, URLs, base64 | file | +| `document auto-rotate` | Auto-rotate documents to the correct orientation | files, directories, URLs, base64 | file | +| `document thumbs` | Extract thumbnail images from documents | files, directories, URLs, base64 | directory | +| `audio waveform` | Generate waveform images from audio | files, directories, URLs, base64 | file | +| `text speak` | Speak text | files, directories, URLs, base64 | file | +| `video thumbs` | Extract thumbnails from videos | files, directories, URLs, base64 | directory | +| `video encode-hls` | Run builtin/encode-hls-video@latest | files, directories, URLs, base64 | directory | +| `image describe` | Describe images as labels or publishable text fields | files, directories, URLs, base64 | file | +| `markdown pdf` | Render Markdown files as PDFs | files, directories, URLs, base64 | file | +| `markdown docx` | Render Markdown files as DOCX documents | files, directories, URLs, base64 | file | +| `file compress` | Compress files | files, directories, URLs, base64 | file | +| `file decompress` | Decompress archives | files, directories, URLs, base64 | directory | + +> At least one of `--out` or `--print-urls` is required on every intent command. + +#### `image generate` + +Generate images from text prompts + +Runs `/image/generate` and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image generate [options] +``` + +**Quick facts** + +- Input: none +- Output: file +- Execution: no input +- Backend: `/image/generate` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--model` | `string` | no | `value` | The AI model to use for image generation. Defaults to google/nano-banana. | +| `--prompt` | `string` | yes | `"A red bicycle in a studio"` | The prompt describing the desired image content. | +| `--format` | `string` | no | `jpg` | Format of the generated image. | +| `--seed` | `number` | no | `1` | Seed for the random number generator. | +| `--aspect-ratio` | `string` | no | `value` | Aspect ratio of the generated image. | +| `--height` | `number` | no | `1` | Height of the generated image. | +| `--width` | `number` | no | `1` | Width of the generated image. | +| `--style` | `string` | no | `value` | Style of the generated image. | +| `--num-outputs` | `number` | no | `1` | Number of image variants to generate. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Examples** + +```bash +# Run the command +transloadit image generate --prompt "A red bicycle in a studio" --out output.png +``` + +#### `preview generate` + +Generate a preview thumbnail + +Runs `/file/preview` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit preview generate --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/file/preview` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | Width of the thumbnail, in pixels. | +| `--height` | `number` | no | `1` | Height of the thumbnail, in pixels. | +| `--resize-strategy` | `string` | no | `crop` | 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. See the list of available [resize strategies](/docs/topics/resize-strategies/) for more details. | +| `--background` | `string` | no | `value` | 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` | `json` | no | `value` | 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. For 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. The parameter defaults to the following definition: ```json { "audio": ["artwork", "waveform", "icon"], "video": ["artwork", "frame", "icon"], "document": ["page", "icon"], "image": ["image", "icon"], "webpage": ["render", "icon"], "archive": ["icon"], "unknown": ["icon"] } ``` | +| `--artwork-outer-color` | `string` | no | `value` | The color used in the outer parts of the artwork's gradient. | +| `--artwork-center-color` | `string` | no | `value` | The color used in the center of the artwork's gradient. | +| `--waveform-center-color` | `string` | no | `value` | 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. | +| `--waveform-outer-color` | `string` | no | `value` | 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. | +| `--waveform-height` | `number` | no | `1` | 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. | +| `--waveform-width` | `number` | no | `1` | 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. | +| `--icon-style` | `string` | no | `square` | 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:

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

`square` style:
![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png) | +| `--icon-text-color` | `string` | no | `value` | The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied. | +| `--icon-text-font` | `string` | no | `value` | 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. | +| `--icon-text-content` | `string` | no | `extension` | 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` | `boolean` | no | `true` | 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/). | +| `--optimize-priority` | `string` | no | `compression-ratio` | 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-progressive` | `boolean` | no | `true` | 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. | +| `--clip-format` | `string` | no | `apng` | The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied. Please 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. | +| `--clip-offset` | `number` | no | `1` | 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. | +| `--clip-duration` | `number` | no | `1` | 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. | +| `--clip-framerate` | `number` | no | `1` | 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. | +| `--clip-loop` | `boolean` | no | `true` | 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 & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit preview generate --input input.file --out output.file +``` + +#### `image remove-background` + +Remove the background from images + +Runs `/image/bgremove` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image remove-background --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/bgremove` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--select` | `string` | no | `foreground` | Region to select and keep in the image. The other region is removed. | +| `--format` | `string` | no | `png` | Format of the generated image. | +| `--provider` | `string` | no | `aws` | Provider to use for removing the background. | +| `--model` | `string` | no | `value` | Provider-specific model to use for removing the background. Mostly intended for testing and evaluation. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit image remove-background --input input.png --out output.png +``` + +#### `image optimize` + +Optimize images without quality loss + +Runs `/image/optimize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image optimize --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/optimize` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--priority` | `string` | no | `compression-ratio` | 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` | `boolean` | no | `true` | 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. | +| `--preserve-meta-data` | `boolean` | no | `true` | 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. | +| `--fix-breaking-images` | `boolean` | no | `true` | 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. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit image optimize --input input.png --out output.png +``` + +#### `image resize` + +Convert, resize, or watermark images + +Runs `/image/resize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image resize --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/resize` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `value` | The output format for the modified image. Some 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/). If `null` (default), then the input image's format will be used as the output format. If you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead. | +| `--width` | `number` | no | `1` | Width of the result in pixels. If not specified, will default to the width of the original. | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image. | +| `--resize-strategy` | `string` | no | `crop` | See the list of available [resize strategies](/docs/topics/resize-strategies/). | +| `--zoom` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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. For example: ```json { "x1": 80, "y1": 100, "x2": "60%", "y2": "80%" } ``` This 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. You can also use a JSON string of such an object with coordinates in similar fashion: ```json "{\"x1\": , \"y1\": , \"x2\": , \"y2\": }" ``` To crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/). | +| `--gravity` | `string` | no | `bottom` | The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined. | +| `--strip` | `boolean` | no | `true` | Strips all metadata from the image. This is useful to keep thumbnails as small as possible. | +| `--alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image. | +| `--preclip-alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`. | +| `--flatten` | `boolean` | no | `true` | 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). To preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter. | +| `--correct-gamma` | `boolean` | no | `true` | Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html). | +| `--quality` | `number` | no | `1` | Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | +| `--adaptive-filtering` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | 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). **Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`. | +| `--frame` | `number` | no | `1` | 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` | `string` | no | `CMY` | 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` | `string` | no | `Bilevel` | 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` | `number` | no | `1` | Applies a sepia tone effect in percent. | +| `--rotation` | `auto` | no | `auto` | 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` | `string` | no | `BZip` | Specifies pixel compression for when the image is written. Compression is disabled by default. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | +| `--blur` | `string` | no | `value` | 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. | +| `--blur-regions` | `json` | no | `value` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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. | +| `--watermark-url` | `string` | no | `value` | 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. | +| `--watermark-position` | `string[]` | no | `bottom` | 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"`. An 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" ]`. This 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. | +| `--watermark-x-offset` | `number` | no | `1` | 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`. Values 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. | +| `--watermark-y-offset` | `number` | no | `1` | 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`. Values 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. | +| `--watermark-size` | `string` | no | `value` | The size of the watermark, as a percentage. For 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. | +| `--watermark-resize-strategy` | `string` | no | `area` | Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`. To 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%"`. For 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). For 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). For 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. For 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. | +| `--watermark-opacity` | `number` | no | `1` | The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque. For 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. | +| `--watermark-repeat-x` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated horizontally across the entire width of the image. This is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark. | +| `--watermark-repeat-y` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated vertically across the entire height of the image. This 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` | `json` | no | `value` | 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: ```json "watermarked": { "use": "resized", "robot": "/image/resize", "text": [ { "text": "© 2018 Transloadit.com", "size": 12, "font": "Ubuntu", "color": "#eeeeee", "valign": "bottom", "align": "right", "x_offset": 16, "y_offset": -10 } ] } ``` | +| `--progressive` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | Make this color transparent within the image. Example: `"255,255,255"`. | +| `--trim-whitespace` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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` | `boolean` | no | `true` | Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping. | +| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image is unsharp, please try increasing density. | +| `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | +| `--shave` | `auto` | no | `value` | 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. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit image resize --input input.png --out output.png +``` + +#### `document convert` + +Convert documents into different formats + +Runs `/document/convert` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document convert --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/convert` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | yes | `pdf` | The desired format for document conversion. | +| `--markdown-format` | `string` | no | `commonmark` | 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. | +| `--markdown-theme` | `string` | no | `bare` | This parameter overhauls your Markdown files styling based on several canned presets. | +| `--pdf-margin` | `string` | no | `value` | PDF Paper margins, separated by `,` and with units. We support the following unit values: `px`, `in`, `cm`, `mm`. Currently this parameter is only supported when converting from `html`. | +| `--pdf-print-background` | `boolean` | no | `true` | Print PDF background graphics. Currently this parameter is only supported when converting from `html`. | +| `--pdf-format` | `string` | no | `A0` | PDF paper format. Currently this parameter is only supported when converting from `html`. | +| `--pdf-display-header-footer` | `boolean` | no | `true` | Display PDF header and footer. Currently this parameter is only supported when converting from `html`. | +| `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - `date` formatted print date - `title` document title - `url` document location - `pageNumber` current page number - `totalPages` total pages in the document Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | +| `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the `pdf_header_template`. Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document convert --input input.pdf --format pdf --out output.pdf +``` + +#### `document optimize` + +Reduce PDF file size + +Runs `/document/optimize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document optimize --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/optimize` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--preset` | `string` | no | `screen` | The quality preset to use for optimization. Each preset provides a different balance between file size and quality: - `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI. - `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI. - `printer` - High quality suitable for printing. Images are kept at 300 DPI. - `prepress` - Highest quality for professional printing. Minimal compression applied. | +| `--image-dpi` | `number` | no | `1` | Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset. Higher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed. Common values: - 72 - Screen viewing - 150 - eBooks and general documents - 300 - Print quality - 600 - High-quality print | +| `--compress-fonts` | `boolean` | no | `true` | Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size. | +| `--subset-fonts` | `boolean` | no | `true` | 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. | +| `--remove-metadata` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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` | `string` | no | `1.4` | 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. - `1.4` - Acrobat 5 compatibility, most widely supported - `1.5` - Acrobat 6 compatibility - `1.6` - Acrobat 7 compatibility - `1.7` - Acrobat 8+ compatibility (default) - `2.0` - PDF 2.0 standard | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document optimize --input input.pdf --out output.pdf +``` + +#### `document auto-rotate` + +Auto-rotate documents to the correct orientation + +Runs `/document/autorotate` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document auto-rotate --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/autorotate` + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document auto-rotate --input input.pdf --out output.pdf +``` + +#### `document thumbs` + +Extract thumbnail images from documents + +Runs `/document/thumbs` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit document thumbs --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/thumbs` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--page` | `number` | no | `1` | 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` | `string` | no | `jpg` | The format of the extracted image(s). If 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` | `number` | no | `1` | 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. If your output format is not `"gif"`, then this parameter does not have any effect. | +| `--width` | `number` | no | `1` | Width of the new image, in pixels. If not specified, will default to the width of the input image | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image | +| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | +| `--background` | `string` | no | `value` | 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). By 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` | `string` | no | `Remove` | Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency. For a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha). | +| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image has a low resolution, please try using the density parameter to resolve that. | +| `--antialiasing` | `boolean` | no | `true` | Controls whether or not antialiasing is used to remove jagged edges from text or images in a document. | +| `--colorspace` | `string` | no | `CMY` | 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"`. 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. | +| `--trim-whitespace` | `boolean` | no | `true` | 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. If you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`. | +| `--pdf-use-cropbox` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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. Also, 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. Turbo 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. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document thumbs --input input.pdf --out output/ +``` + +#### `audio waveform` + +Generate waveform images from audio + +Runs `/audio/waveform` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit audio waveform --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/audio/waveform` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--ffmpeg` | `json` | no | `value` | 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` | `string` | no | `image` | 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` | `number` | no | `1` | The width of the resulting image if the format `"image"` was selected. | +| `--height` | `number` | no | `1` | The height of the resulting image if the format `"image"` was selected. | +| `--antialiasing` | `auto` | no | `0` | 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. | +| `--background-color` | `string` | no | `value` | The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected. | +| `--center-color` | `string` | no | `value` | The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--outer-color` | `string` | no | `value` | The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--style` | `string` | no | `v0` | Waveform style version. - `"v0"`: Legacy waveform generation (default). - `"v1"`: Advanced waveform generation with additional parameters. For backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2). | +| `--split-channels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel. | +| `--zoom` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`. | +| `--pixels-per-second` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`. | +| `--bits` | `number` | no | `8` | Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16. | +| `--start` | `number` | no | `1` | Available when style is `"v1"`. Start time in seconds. | +| `--end` | `number` | no | `1` | Available when style is `"v1"`. End time in seconds (0 means end of audio). | +| `--colors` | `string` | no | `audition` | Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity". | +| `--border-color` | `string` | no | `value` | Available when style is `"v1"`. Border color in "rrggbbaa" format. | +| `--waveform-style` | `string` | no | `normal` | Available when style is `"v1"`. Waveform style. Can be "normal" or "bars". | +| `--bar-width` | `number` | no | `1` | Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars". | +| `--bar-gap` | `number` | no | `1` | Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars". | +| `--bar-style` | `string` | no | `square` | Available when style is `"v1"`. Bar style when waveform_style is "bars". | +| `--axis-label-color` | `string` | no | `value` | Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format. | +| `--no-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels. | +| `--with-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels. | +| `--amplitude-scale` | `number` | no | `1` | Available when style is `"v1"`. Amplitude scale factor. | +| `--compression` | `number` | no | `1` | Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit audio waveform --input input.mp3 --out output.png +``` + +#### `text speak` + +Speak text + +Runs `/text/speak` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit text speak --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/text/speak` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--prompt` | `string` | no | `"A red bicycle in a studio"` | Which text to speak. You can also set this to `null` and supply an input text file. | +| `--provider` | `string` | yes | `aws` | Which AI provider to leverage. Transloadit 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. | +| `--target-language` | `string` | no | `en-US` | The written language of the document. This will also be the language of the spoken text. The 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` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | +| `--ssml` | `boolean` | no | `true` | 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. Please 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). | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit text speak --input input.pdf --provider aws --out output.mp3 +``` + +#### `video thumbs` + +Extract thumbnails from videos + +Runs `/video/thumbs` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit video thumbs --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/video/thumbs` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--ffmpeg` | `json` | no | `value` | 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` | `number` | no | `1` | 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. The 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. To extract thumbnails for specific timestamps, use the `offsets` parameter. | +| `--offsets` | `auto` | no | `value` | 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%" ]`. This option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored. | +| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | The width of the thumbnail, in pixels. Defaults to the original width of the video. | +| `--height` | `number` | no | `1` | The height of the thumbnail, in pixels. Defaults to the original height of the video. | +| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | +| `--background` | `string` | no | `value` | 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` | `number` | no | `0` | 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. | +| `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit video thumbs --input input.mp4 --out output/ +``` + +#### `video encode-hls` + +Run builtin/encode-hls-video@latest + +Runs the `builtin/encode-hls-video@latest` template and writes the outputs to `--out`. + +**Usage** + +```bash +npx transloadit video encode-hls --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `builtin/encode-hls-video@latest` + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit video encode-hls --input input.mp4 --out output/ +``` + +#### `image describe` + +Describe images as labels or publishable text fields + +Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`. + +**Usage** + +```bash +npx transloadit image describe --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `image-describe` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | +| `--for` | `string` | no | — | Use a named output profile, currently: wordpress | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-4-6) | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the JSON result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Examples** + +```bash +# 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 +``` + +#### `markdown pdf` + +Render Markdown files as PDFs + +Runs `/document/convert` with `format: pdf`, letting the backend render Markdown and preserve features such as internal heading links in the generated PDF. + +**Usage** + +```bash +npx transloadit markdown pdf --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `markdown-pdf` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | +| `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the rendered PDF to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Examples** + +```bash +# Render a Markdown file as a PDF file +transloadit markdown pdf --input README.md --out README.pdf +# Print a temporary result URL without downloading locally +transloadit markdown pdf --input README.md --print-urls +``` + +#### `markdown docx` + +Render Markdown files as DOCX documents + +Runs `/document/convert` with `format: docx`, letting the backend render Markdown and convert it into a Word document. + +**Usage** + +```bash +npx transloadit markdown docx --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `markdown-docx` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | +| `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the rendered DOCX to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Examples** + +```bash +# Render a Markdown file as a DOCX file +transloadit markdown docx --input README.md --out README.docx +# Print a temporary result URL without downloading locally +transloadit markdown docx --input README.md --print-urls +``` + +#### `file compress` + +Compress files + +Runs `/file/compress` for the provided inputs and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit file compress --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: single assembly +- Backend: `/file/compress` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `zip` | The format of the archive to be created. Supported values are `"tar"` and `"zip"`. Note that `"tar"` without setting `gzip` to `true` results in an archive that's not compressed in any way. | +| `--gzip` | `boolean` | no | `true` | Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format. | +| `--password` | `string` | no | `value` | 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. This parameter has no effect if the format parameter is anything other than `"zip"`. | +| `--compression-level` | `number` | no | `1` | 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. If 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. | +| `--file-layout` | `string` | no | `advanced` | 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. Files with same names are numbered in the `"simple"` file layout to avoid naming collisions. | +| `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | + +**Examples** + +```bash +# Run the command +transloadit file compress --input input.file --out output.file +``` + +#### `file decompress` + +Decompress archives + +Runs `/file/decompress` on each input file and writes the results to `--out`. + +**Usage** + ```bash -# Generate an image from a text prompt -npx transloadit image generate --prompt "A red bicycle in a studio" --out bicycle.png +npx transloadit file decompress --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/file/decompress` + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | -# Generate a preview for any input path or URL -npx transloadit preview generate --input https://example.com/file.pdf --out preview.png +**Processing flags** -# Paste base64 input directly into an intent command -npx transloadit document convert --input-base64 "$(base64 -i input.txt)" --format pdf --out output.pdf +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | -# Encode a video into an HLS package -npx transloadit video encode-hls --input input.mp4 --out dist/hls +**Examples** + +```bash +# Run the command +transloadit file decompress --input input.file --out output/ ``` -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: @@ -879,3 +1957,5 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo ## Development See [CONTRIBUTING](./CONTRIBUTING.md). + + diff --git a/packages/node/docs/intent-commands.md b/packages/node/docs/intent-commands.md new file mode 100644 index 00000000..019ba572 --- /dev/null +++ b/packages/node/docs/intent-commands.md @@ -0,0 +1,1094 @@ +# Intent Command Reference + +> Generated by `yarn workspace @transloadit/node sync:intent-docs`. Do not edit by hand. + +## At a glance + +Intent commands are the fastest path to common one-off tasks from the CLI. +Use `--print-urls` when you want temporary result URLs without downloading locally. +All intent commands also support the global CLI flags `--json`, `--log-level`, `--endpoint`, and `--help`. + +| Command | What it does | Input | Output | +| --- | --- | --- | --- | +| `image generate` | Generate images from text prompts | none | file | +| `preview generate` | Generate a preview thumbnail | files, directories, URLs, base64 | file | +| `image remove-background` | Remove the background from images | files, directories, URLs, base64 | file | +| `image optimize` | Optimize images without quality loss | files, directories, URLs, base64 | file | +| `image resize` | Convert, resize, or watermark images | files, directories, URLs, base64 | file | +| `document convert` | Convert documents into different formats | files, directories, URLs, base64 | file | +| `document optimize` | Reduce PDF file size | files, directories, URLs, base64 | file | +| `document auto-rotate` | Auto-rotate documents to the correct orientation | files, directories, URLs, base64 | file | +| `document thumbs` | Extract thumbnail images from documents | files, directories, URLs, base64 | directory | +| `audio waveform` | Generate waveform images from audio | files, directories, URLs, base64 | file | +| `text speak` | Speak text | files, directories, URLs, base64 | file | +| `video thumbs` | Extract thumbnails from videos | files, directories, URLs, base64 | directory | +| `video encode-hls` | Run builtin/encode-hls-video@latest | files, directories, URLs, base64 | directory | +| `image describe` | Describe images as labels or publishable text fields | files, directories, URLs, base64 | file | +| `markdown pdf` | Render Markdown files as PDFs | files, directories, URLs, base64 | file | +| `markdown docx` | Render Markdown files as DOCX documents | files, directories, URLs, base64 | file | +| `file compress` | Compress files | files, directories, URLs, base64 | file | +| `file decompress` | Decompress archives | files, directories, URLs, base64 | directory | + +> At least one of `--out` or `--print-urls` is required on every intent command. + +## `image generate` + +Generate images from text prompts + +Runs `/image/generate` and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image generate [options] +``` + +**Quick facts** + +- Input: none +- Output: file +- Execution: no input +- Backend: `/image/generate` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--model` | `string` | no | `value` | The AI model to use for image generation. Defaults to google/nano-banana. | +| `--prompt` | `string` | yes | `"A red bicycle in a studio"` | The prompt describing the desired image content. | +| `--format` | `string` | no | `jpg` | Format of the generated image. | +| `--seed` | `number` | no | `1` | Seed for the random number generator. | +| `--aspect-ratio` | `string` | no | `value` | Aspect ratio of the generated image. | +| `--height` | `number` | no | `1` | Height of the generated image. | +| `--width` | `number` | no | `1` | Width of the generated image. | +| `--style` | `string` | no | `value` | Style of the generated image. | +| `--num-outputs` | `number` | no | `1` | Number of image variants to generate. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Examples** + +```bash +# Run the command +transloadit image generate --prompt "A red bicycle in a studio" --out output.png +``` + +## `preview generate` + +Generate a preview thumbnail + +Runs `/file/preview` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit preview generate --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/file/preview` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | Width of the thumbnail, in pixels. | +| `--height` | `number` | no | `1` | Height of the thumbnail, in pixels. | +| `--resize-strategy` | `string` | no | `crop` | 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. See the list of available [resize strategies](/docs/topics/resize-strategies/) for more details. | +| `--background` | `string` | no | `value` | 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` | `json` | no | `value` | 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. For 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. The parameter defaults to the following definition: ```json { "audio": ["artwork", "waveform", "icon"], "video": ["artwork", "frame", "icon"], "document": ["page", "icon"], "image": ["image", "icon"], "webpage": ["render", "icon"], "archive": ["icon"], "unknown": ["icon"] } ``` | +| `--artwork-outer-color` | `string` | no | `value` | The color used in the outer parts of the artwork's gradient. | +| `--artwork-center-color` | `string` | no | `value` | The color used in the center of the artwork's gradient. | +| `--waveform-center-color` | `string` | no | `value` | 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. | +| `--waveform-outer-color` | `string` | no | `value` | 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. | +| `--waveform-height` | `number` | no | `1` | 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. | +| `--waveform-width` | `number` | no | `1` | 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. | +| `--icon-style` | `string` | no | `square` | 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:

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

`square` style:
![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png) | +| `--icon-text-color` | `string` | no | `value` | The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied. | +| `--icon-text-font` | `string` | no | `value` | 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. | +| `--icon-text-content` | `string` | no | `extension` | 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` | `boolean` | no | `true` | 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/). | +| `--optimize-priority` | `string` | no | `compression-ratio` | 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-progressive` | `boolean` | no | `true` | 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. | +| `--clip-format` | `string` | no | `apng` | The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied. Please 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. | +| `--clip-offset` | `number` | no | `1` | 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. | +| `--clip-duration` | `number` | no | `1` | 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. | +| `--clip-framerate` | `number` | no | `1` | 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. | +| `--clip-loop` | `boolean` | no | `true` | 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 & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit preview generate --input input.file --out output.file +``` + +## `image remove-background` + +Remove the background from images + +Runs `/image/bgremove` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image remove-background --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/bgremove` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--select` | `string` | no | `foreground` | Region to select and keep in the image. The other region is removed. | +| `--format` | `string` | no | `png` | Format of the generated image. | +| `--provider` | `string` | no | `aws` | Provider to use for removing the background. | +| `--model` | `string` | no | `value` | Provider-specific model to use for removing the background. Mostly intended for testing and evaluation. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit image remove-background --input input.png --out output.png +``` + +## `image optimize` + +Optimize images without quality loss + +Runs `/image/optimize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image optimize --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/optimize` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--priority` | `string` | no | `compression-ratio` | 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` | `boolean` | no | `true` | 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. | +| `--preserve-meta-data` | `boolean` | no | `true` | 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. | +| `--fix-breaking-images` | `boolean` | no | `true` | 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. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit image optimize --input input.png --out output.png +``` + +## `image resize` + +Convert, resize, or watermark images + +Runs `/image/resize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image resize --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/resize` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `value` | The output format for the modified image. Some 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/). If `null` (default), then the input image's format will be used as the output format. If you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead. | +| `--width` | `number` | no | `1` | Width of the result in pixels. If not specified, will default to the width of the original. | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image. | +| `--resize-strategy` | `string` | no | `crop` | See the list of available [resize strategies](/docs/topics/resize-strategies/). | +| `--zoom` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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. For example: ```json { "x1": 80, "y1": 100, "x2": "60%", "y2": "80%" } ``` This 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. You can also use a JSON string of such an object with coordinates in similar fashion: ```json "{\"x1\": , \"y1\": , \"x2\": , \"y2\": }" ``` To crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/). | +| `--gravity` | `string` | no | `bottom` | The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined. | +| `--strip` | `boolean` | no | `true` | Strips all metadata from the image. This is useful to keep thumbnails as small as possible. | +| `--alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image. | +| `--preclip-alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`. | +| `--flatten` | `boolean` | no | `true` | 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). To preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter. | +| `--correct-gamma` | `boolean` | no | `true` | Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html). | +| `--quality` | `number` | no | `1` | Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | +| `--adaptive-filtering` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | 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). **Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`. | +| `--frame` | `number` | no | `1` | 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` | `string` | no | `CMY` | 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` | `string` | no | `Bilevel` | 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` | `number` | no | `1` | Applies a sepia tone effect in percent. | +| `--rotation` | `auto` | no | `auto` | 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` | `string` | no | `BZip` | Specifies pixel compression for when the image is written. Compression is disabled by default. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | +| `--blur` | `string` | no | `value` | 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. | +| `--blur-regions` | `json` | no | `value` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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. | +| `--watermark-url` | `string` | no | `value` | 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. | +| `--watermark-position` | `string[]` | no | `bottom` | 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"`. An 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" ]`. This 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. | +| `--watermark-x-offset` | `number` | no | `1` | 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`. Values 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. | +| `--watermark-y-offset` | `number` | no | `1` | 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`. Values 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. | +| `--watermark-size` | `string` | no | `value` | The size of the watermark, as a percentage. For 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. | +| `--watermark-resize-strategy` | `string` | no | `area` | Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`. To 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%"`. For 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). For 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). For 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. For 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. | +| `--watermark-opacity` | `number` | no | `1` | The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque. For 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. | +| `--watermark-repeat-x` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated horizontally across the entire width of the image. This is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark. | +| `--watermark-repeat-y` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated vertically across the entire height of the image. This 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` | `json` | no | `value` | 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: ```json "watermarked": { "use": "resized", "robot": "/image/resize", "text": [ { "text": "© 2018 Transloadit.com", "size": 12, "font": "Ubuntu", "color": "#eeeeee", "valign": "bottom", "align": "right", "x_offset": 16, "y_offset": -10 } ] } ``` | +| `--progressive` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | Make this color transparent within the image. Example: `"255,255,255"`. | +| `--trim-whitespace` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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` | `boolean` | no | `true` | Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping. | +| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image is unsharp, please try increasing density. | +| `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | +| `--shave` | `auto` | no | `value` | 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. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit image resize --input input.png --out output.png +``` + +## `document convert` + +Convert documents into different formats + +Runs `/document/convert` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document convert --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/convert` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | yes | `pdf` | The desired format for document conversion. | +| `--markdown-format` | `string` | no | `commonmark` | 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. | +| `--markdown-theme` | `string` | no | `bare` | This parameter overhauls your Markdown files styling based on several canned presets. | +| `--pdf-margin` | `string` | no | `value` | PDF Paper margins, separated by `,` and with units. We support the following unit values: `px`, `in`, `cm`, `mm`. Currently this parameter is only supported when converting from `html`. | +| `--pdf-print-background` | `boolean` | no | `true` | Print PDF background graphics. Currently this parameter is only supported when converting from `html`. | +| `--pdf-format` | `string` | no | `A0` | PDF paper format. Currently this parameter is only supported when converting from `html`. | +| `--pdf-display-header-footer` | `boolean` | no | `true` | Display PDF header and footer. Currently this parameter is only supported when converting from `html`. | +| `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - `date` formatted print date - `title` document title - `url` document location - `pageNumber` current page number - `totalPages` total pages in the document Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | +| `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the `pdf_header_template`. Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document convert --input input.pdf --format pdf --out output.pdf +``` + +## `document optimize` + +Reduce PDF file size + +Runs `/document/optimize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document optimize --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/optimize` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--preset` | `string` | no | `screen` | The quality preset to use for optimization. Each preset provides a different balance between file size and quality: - `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI. - `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI. - `printer` - High quality suitable for printing. Images are kept at 300 DPI. - `prepress` - Highest quality for professional printing. Minimal compression applied. | +| `--image-dpi` | `number` | no | `1` | Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset. Higher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed. Common values: - 72 - Screen viewing - 150 - eBooks and general documents - 300 - Print quality - 600 - High-quality print | +| `--compress-fonts` | `boolean` | no | `true` | Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size. | +| `--subset-fonts` | `boolean` | no | `true` | 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. | +| `--remove-metadata` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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` | `string` | no | `1.4` | 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. - `1.4` - Acrobat 5 compatibility, most widely supported - `1.5` - Acrobat 6 compatibility - `1.6` - Acrobat 7 compatibility - `1.7` - Acrobat 8+ compatibility (default) - `2.0` - PDF 2.0 standard | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document optimize --input input.pdf --out output.pdf +``` + +## `document auto-rotate` + +Auto-rotate documents to the correct orientation + +Runs `/document/autorotate` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document auto-rotate --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/autorotate` + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document auto-rotate --input input.pdf --out output.pdf +``` + +## `document thumbs` + +Extract thumbnail images from documents + +Runs `/document/thumbs` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit document thumbs --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/thumbs` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--page` | `number` | no | `1` | 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` | `string` | no | `jpg` | The format of the extracted image(s). If 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` | `number` | no | `1` | 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. If your output format is not `"gif"`, then this parameter does not have any effect. | +| `--width` | `number` | no | `1` | Width of the new image, in pixels. If not specified, will default to the width of the input image | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image | +| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | +| `--background` | `string` | no | `value` | 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). By 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` | `string` | no | `Remove` | Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency. For a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha). | +| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image has a low resolution, please try using the density parameter to resolve that. | +| `--antialiasing` | `boolean` | no | `true` | Controls whether or not antialiasing is used to remove jagged edges from text or images in a document. | +| `--colorspace` | `string` | no | `CMY` | 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"`. 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. | +| `--trim-whitespace` | `boolean` | no | `true` | 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. If you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`. | +| `--pdf-use-cropbox` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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. Also, 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. Turbo 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. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit document thumbs --input input.pdf --out output/ +``` + +## `audio waveform` + +Generate waveform images from audio + +Runs `/audio/waveform` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit audio waveform --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/audio/waveform` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--ffmpeg` | `json` | no | `value` | 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` | `string` | no | `image` | 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` | `number` | no | `1` | The width of the resulting image if the format `"image"` was selected. | +| `--height` | `number` | no | `1` | The height of the resulting image if the format `"image"` was selected. | +| `--antialiasing` | `auto` | no | `0` | 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. | +| `--background-color` | `string` | no | `value` | The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected. | +| `--center-color` | `string` | no | `value` | The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--outer-color` | `string` | no | `value` | The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--style` | `string` | no | `v0` | Waveform style version. - `"v0"`: Legacy waveform generation (default). - `"v1"`: Advanced waveform generation with additional parameters. For backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2). | +| `--split-channels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel. | +| `--zoom` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`. | +| `--pixels-per-second` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`. | +| `--bits` | `number` | no | `8` | Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16. | +| `--start` | `number` | no | `1` | Available when style is `"v1"`. Start time in seconds. | +| `--end` | `number` | no | `1` | Available when style is `"v1"`. End time in seconds (0 means end of audio). | +| `--colors` | `string` | no | `audition` | Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity". | +| `--border-color` | `string` | no | `value` | Available when style is `"v1"`. Border color in "rrggbbaa" format. | +| `--waveform-style` | `string` | no | `normal` | Available when style is `"v1"`. Waveform style. Can be "normal" or "bars". | +| `--bar-width` | `number` | no | `1` | Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars". | +| `--bar-gap` | `number` | no | `1` | Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars". | +| `--bar-style` | `string` | no | `square` | Available when style is `"v1"`. Bar style when waveform_style is "bars". | +| `--axis-label-color` | `string` | no | `value` | Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format. | +| `--no-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels. | +| `--with-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels. | +| `--amplitude-scale` | `number` | no | `1` | Available when style is `"v1"`. Amplitude scale factor. | +| `--compression` | `number` | no | `1` | Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit audio waveform --input input.mp3 --out output.png +``` + +## `text speak` + +Speak text + +Runs `/text/speak` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit text speak --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/text/speak` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--prompt` | `string` | no | `"A red bicycle in a studio"` | Which text to speak. You can also set this to `null` and supply an input text file. | +| `--provider` | `string` | yes | `aws` | Which AI provider to leverage. Transloadit 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. | +| `--target-language` | `string` | no | `en-US` | The written language of the document. This will also be the language of the spoken text. The 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` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | +| `--ssml` | `boolean` | no | `true` | 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. Please 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). | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit text speak --input input.pdf --provider aws --out output.mp3 +``` + +## `video thumbs` + +Extract thumbnails from videos + +Runs `/video/thumbs` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit video thumbs --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/video/thumbs` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--ffmpeg` | `json` | no | `value` | 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` | `number` | no | `1` | 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. The 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. To extract thumbnails for specific timestamps, use the `offsets` parameter. | +| `--offsets` | `auto` | no | `value` | 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%" ]`. This option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored. | +| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | The width of the thumbnail, in pixels. Defaults to the original width of the video. | +| `--height` | `number` | no | `1` | The height of the thumbnail, in pixels. Defaults to the original height of the video. | +| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | +| `--background` | `string` | no | `value` | 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` | `number` | no | `0` | 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. | +| `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit video thumbs --input input.mp4 --out output/ +``` + +## `video encode-hls` + +Run builtin/encode-hls-video@latest + +Runs the `builtin/encode-hls-video@latest` template and writes the outputs to `--out`. + +**Usage** + +```bash +npx transloadit video encode-hls --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `builtin/encode-hls-video@latest` + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit video encode-hls --input input.mp4 --out output/ +``` + +## `image describe` + +Describe images as labels or publishable text fields + +Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`. + +**Usage** + +```bash +npx transloadit image describe --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `image-describe` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | +| `--for` | `string` | no | — | Use a named output profile, currently: wordpress | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-4-6) | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the JSON result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Examples** + +```bash +# 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 +``` + +## `markdown pdf` + +Render Markdown files as PDFs + +Runs `/document/convert` with `format: pdf`, letting the backend render Markdown and preserve features such as internal heading links in the generated PDF. + +**Usage** + +```bash +npx transloadit markdown pdf --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `markdown-pdf` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | +| `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the rendered PDF to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Examples** + +```bash +# Render a Markdown file as a PDF file +transloadit markdown pdf --input README.md --out README.pdf +# Print a temporary result URL without downloading locally +transloadit markdown pdf --input README.md --print-urls +``` + +## `markdown docx` + +Render Markdown files as DOCX documents + +Runs `/document/convert` with `format: docx`, letting the backend render Markdown and convert it into a Word document. + +**Usage** + +```bash +npx transloadit markdown docx --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `markdown-docx` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | +| `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the rendered DOCX to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Examples** + +```bash +# Render a Markdown file as a DOCX file +transloadit markdown docx --input README.md --out README.docx +# Print a temporary result URL without downloading locally +transloadit markdown docx --input README.md --print-urls +``` + +## `file compress` + +Compress files + +Runs `/file/compress` for the provided inputs and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit file compress --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: file +- Execution: single assembly +- Backend: `/file/compress` + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `zip` | The format of the archive to be created. Supported values are `"tar"` and `"zip"`. Note that `"tar"` without setting `gzip` to `true` results in an archive that's not compressed in any way. | +| `--gzip` | `boolean` | no | `true` | Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format. | +| `--password` | `string` | no | `value` | 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. This parameter has no effect if the format parameter is anything other than `"zip"`. | +| `--compression-level` | `number` | no | `1` | 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. If 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. | +| `--file-layout` | `string` | no | `advanced` | 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. Files with same names are numbered in the `"simple"` file layout to avoid naming collisions. | +| `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | + +**Examples** + +```bash +# Run the command +transloadit file compress --input input.file --out output.file +``` + +## `file decompress` + +Decompress archives + +Runs `/file/decompress` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit file decompress --input [options] +``` + +**Quick facts** + +- Input: files, directories, URLs, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/file/decompress` + +**Input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +**Examples** + +```bash +# Run the command +transloadit file decompress --input input.file --out output/ +``` diff --git a/packages/node/package.json b/packages/node/package.json index 6d90e59c..d7d9427b 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -82,7 +82,8 @@ "src": "./src" }, "scripts": { - "check": "yarn lint:ts && yarn fix && yarn test:unit", + "check": "yarn sync:intent-docs && yarn lint:ts && yarn fix && yarn test:unit", + "sync:intent-docs": "node src/cli/generateIntentDocs.ts", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", diff --git a/packages/node/src/cli/generateIntentDocs.ts b/packages/node/src/cli/generateIntentDocs.ts new file mode 100644 index 00000000..bbe9e3b1 --- /dev/null +++ b/packages/node/src/cli/generateIntentDocs.ts @@ -0,0 +1,401 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { IntentDefinition } from './intentCommandSpecs.ts' +import type { ResolvedIntentCommandDefinition } from './intentCommands.ts' +import { resolveIntentCommandDefinitions } from './intentCommands.ts' +import type { IntentOptionDefinition } from './intentRuntime.ts' +import { getIntentOptionDefinitions } from './intentRuntime.ts' + +interface DocOptionRow { + description: string + example: string + flags: string + required: string + type: string +} + +function inlineCode(value: string): string { + return `\`${value.replaceAll('`', '\\`')}\`` +} + +function escapeTableCell(value: string): string { + return value.replaceAll('\n', ' ').replaceAll('|', '\\|') +} + +function renderTable(headers: string[], rows: string[][]): string { + const renderedRows = rows.map((row) => `| ${row.map(escapeTableCell).join(' | ')} |`) + return [ + `| ${headers.join(' | ')} |`, + `| ${headers.map(() => '---').join(' | ')} |`, + ...renderedRows, + ].join('\n') +} + +function getInputSummary(definition: ResolvedIntentCommandDefinition): string { + if (definition.runnerKind === 'no-input') { + return 'none' + } + + return 'files, directories, URLs, base64' +} + +function getOutputSummary(definition: ResolvedIntentCommandDefinition): string { + return definition.intentDefinition.outputMode === 'directory' ? 'directory' : 'file' +} + +function getExecutionSummary(definition: ResolvedIntentCommandDefinition): string { + switch (definition.runnerKind) { + case 'bundled': + return 'single assembly' + case 'no-input': + return 'no input' + case 'standard': + return 'per-file; supports `--single-assembly` and `--watch`' + case 'watchable': + return 'per-file; supports `--watch`' + } +} + +function getBackendSummary(catalogDefinition: IntentDefinition): string { + if (catalogDefinition.kind === 'robot') { + return inlineCode(catalogDefinition.robot) + } + + if (catalogDefinition.kind === 'template') { + return inlineCode(catalogDefinition.templateId) + } + + return `semantic alias ${inlineCode(catalogDefinition.semantic)}` +} + +function getUsage(definition: ResolvedIntentCommandDefinition): string { + const parts = ['npx transloadit', ...definition.paths] + if (definition.runnerKind !== 'no-input') { + parts.push('--input', '') + } + parts.push('[options]') + return parts.join(' ') +} + +function formatOptionType(kind: IntentOptionDefinition['kind']): string { + switch (kind) { + case 'auto': + return 'auto' + case 'boolean': + return 'boolean' + case 'json': + return 'json' + case 'number': + return 'number' + case 'string': + return 'string' + case 'string-array': + return 'string[]' + } +} + +function getExampleValue(field: IntentOptionDefinition): string { + const candidate = (field as IntentOptionDefinition & { exampleValue?: unknown }).exampleValue + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate + } + + return '—' +} + +function getCommandOptionRows(definition: ResolvedIntentCommandDefinition): DocOptionRow[] { + return getIntentOptionDefinitions(definition.intentDefinition).map((field) => ({ + flags: field.optionFlags, + type: formatOptionType(field.kind), + required: field.required ? 'yes' : 'no', + example: getExampleValue(field), + description: field.description ?? '—', + })) +} + +function getInputOutputRows(definition: ResolvedIntentCommandDefinition): DocOptionRow[] { + const outputType = definition.intentDefinition.outputMode === 'directory' ? 'directory' : 'path' + + if (definition.runnerKind === 'no-input') { + return [ + { + flags: '--out, -o', + type: outputType, + required: 'yes*', + example: definition.intentDefinition.outputMode === 'directory' ? 'output/' : 'output.file', + description: definition.intentDefinition.outputDescription, + }, + { + flags: '--print-urls', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Print temporary result URLs after completion', + }, + ] + } + + return [ + { + flags: '--input, -i', + type: 'path | dir | url | -', + required: 'varies', + example: 'input.file', + description: 'Provide an input path, directory, URL, or - for stdin', + }, + { + flags: '--input-base64', + type: 'base64 | data URL', + required: 'no', + example: 'data:text/plain;base64,SGVsbG8=', + description: 'Provide base64-encoded input content directly', + }, + { + flags: '--out, -o', + type: outputType, + required: 'yes*', + example: definition.intentDefinition.outputMode === 'directory' ? 'output/' : 'output.file', + description: definition.intentDefinition.outputDescription, + }, + { + flags: '--print-urls', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Print temporary result URLs after completion', + }, + ] +} + +function getProcessingRows(definition: ResolvedIntentCommandDefinition): DocOptionRow[] { + if (definition.runnerKind === 'no-input') { + return [] + } + + const rows: DocOptionRow[] = [ + { + flags: '--recursive, -r', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Enumerate input directories recursively', + }, + { + flags: '--delete-after-processing, -d', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Delete input files after they are processed', + }, + { + flags: '--reprocess-stale', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Process inputs even if output is newer', + }, + ] + + if (definition.runnerKind === 'standard' || definition.runnerKind === 'watchable') { + rows.push( + { + flags: '--watch, -w', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Watch inputs for changes', + }, + { + flags: '--concurrency, -c', + type: 'number', + required: 'no', + example: '5', + description: 'Maximum number of concurrent assemblies (default: 5)', + }, + ) + } + + if (definition.runnerKind === 'standard') { + rows.push({ + flags: '--single-assembly', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Pass all input files to a single assembly instead of one assembly per file', + }) + } + + return rows +} + +function renderOptionSection(title: string, rows: DocOptionRow[]): string[] { + if (rows.length === 0) { + return [] + } + + return [ + `**${title}**`, + '', + renderTable( + ['Flag', 'Type', 'Required', 'Example', 'Description'], + rows.map((row) => [ + inlineCode(row.flags), + inlineCode(row.type), + row.required, + row.example === '—' ? row.example : inlineCode(row.example), + row.description, + ]), + ), + '', + ] +} + +function renderExamples(examples: Array<[string, string]>): string { + const lines: string[] = ['```bash'] + + for (const [label, command] of examples) { + lines.push(`# ${label}`) + lines.push(command) + } + + lines.push('```') + return lines.join('\n') +} + +function renderIntentSection( + definition: ResolvedIntentCommandDefinition, + headingLevel: number, +): string { + const heading = '#'.repeat(headingLevel) + const commandLabel = definition.paths.join(' ') + const lines: string[] = [ + `${heading} ${inlineCode(commandLabel)}`, + '', + definition.description, + '', + definition.details, + '', + '**Usage**', + '', + '```bash', + getUsage(definition), + '```', + '', + '**Quick facts**', + '', + `- Input: ${getInputSummary(definition)}`, + `- Output: ${getOutputSummary(definition)}`, + `- Execution: ${getExecutionSummary(definition)}`, + `- Backend: ${getBackendSummary(definition.catalogDefinition)}`, + '', + ...renderOptionSection('Command options', getCommandOptionRows(definition)), + ...renderOptionSection('Input & output flags', getInputOutputRows(definition)), + ...renderOptionSection('Processing flags', getProcessingRows(definition)), + '**Examples**', + '', + renderExamples(definition.examples), + '', + ] + + return lines.join('\n') +} + +function renderAtAGlanceTable(definitions: ResolvedIntentCommandDefinition[]): string { + return renderTable( + ['Command', 'What it does', 'Input', 'Output'], + definitions.map((definition) => [ + inlineCode(definition.paths.join(' ')), + definition.description, + getInputSummary(definition), + getOutputSummary(definition), + ]), + ) +} + +function renderIntentDocsBody({ + definitions, + headingLevel, +}: { + definitions: ResolvedIntentCommandDefinition[] + headingLevel: number +}): string { + const heading = '#'.repeat(headingLevel) + const lines: string[] = [ + `${heading} At a glance`, + '', + 'Intent commands are the fastest path to common one-off tasks from the CLI.', + 'Use `--print-urls` when you want temporary result URLs without downloading locally.', + 'All intent commands also support the global CLI flags `--json`, `--log-level`, `--endpoint`, and `--help`.', + '', + renderAtAGlanceTable(definitions), + '', + '> At least one of `--out` or `--print-urls` is required on every intent command.', + '', + ] + + for (const definition of definitions) { + lines.push(renderIntentSection(definition, headingLevel)) + } + + return lines.join('\n').trim() +} + +function replaceGeneratedBlock({ + endMarker, + markdown, + readme, + startMarker, +}: { + endMarker: string + markdown: string + readme: string + startMarker: string +}): string { + const startIndex = readme.indexOf(startMarker) + const endIndex = readme.indexOf(endMarker) + if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { + throw new Error('README intent docs markers are missing or malformed') + } + + const before = readme.slice(0, startIndex + startMarker.length) + const after = readme.slice(endIndex) + return `${before}\n\n${markdown}\n\n${after}` +} + +async function main(): Promise { + const definitions = resolveIntentCommandDefinitions() + const readmeUrl = new URL('../../README.md', import.meta.url) + const docsUrl = new URL('../../docs/intent-commands.md', import.meta.url) + const startMarker = '' + const endMarker = '' + + const readme = await readFile(readmeUrl, 'utf8') + const readmeFragment = renderIntentDocsBody({ definitions, headingLevel: 4 }) + const fullDoc = [ + '# Intent Command Reference', + '', + '> Generated by `yarn workspace @transloadit/node sync:intent-docs`. Do not edit by hand.', + '', + renderIntentDocsBody({ definitions, headingLevel: 2 }), + ].join('\n') + + const nextReadme = replaceGeneratedBlock({ + endMarker, + markdown: readmeFragment, + readme, + startMarker, + }) + + await mkdir(dirname(docsUrl.pathname), { recursive: true }) + await writeFile(docsUrl, `${fullDoc}\n`) + await writeFile(readmeUrl, `${nextReadme}\n`) +} + +main().catch((error) => { + if (!(error instanceof Error)) { + throw new Error(`Was thrown a non-error: ${String(error)}`) + } + console.error(error) + process.exit(1) +}) diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index aca341f4..be515cec 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -64,6 +64,10 @@ type BuiltIntentCommandDefinition = IntentCommandDefinition & { intentDefinition: IntentFileCommandDefinition | IntentNoInputCommandDefinition } +export type ResolvedIntentCommandDefinition = BuiltIntentCommandDefinition & { + catalogDefinition: IntentDefinition +} + const hiddenFieldNames = new Set([ 'ffmpeg_stack', 'force_accept', @@ -439,6 +443,13 @@ function resolveIntent(definition: IntentDefinition): BuiltIntentCommandDefiniti return resolveTemplateIntent(definition) } +export function resolveIntentCommandDefinitions(): ResolvedIntentCommandDefinition[] { + return intentCatalog.map((definition) => ({ + ...resolveIntent(definition), + catalogDefinition: definition, + })) +} + function getBaseClass(spec: BuiltIntentCommandDefinition): IntentBaseClass { if (spec.runnerKind === 'no-input') { return GeneratedNoInputIntentCommand @@ -487,4 +498,4 @@ function createIntentCommandClass(spec: BuiltIntentCommandDefinition): CommandCl return RuntimeIntentCommand as unknown as CommandClass } -export const intentCommands = intentCatalog.map(resolveIntent).map(createIntentCommandClass) +export const intentCommands = resolveIntentCommandDefinitions().map(createIntentCommandClass) From 1314537f32afb146b17cd23ede36ffe4d60aae65 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 08:42:31 +0200 Subject: [PATCH 57/69] docs(node): polish generated intent reference --- packages/node/README.md | 385 ++++++++++---------- packages/node/docs/intent-commands.md | 383 ++++++++++--------- packages/node/src/cli/generateIntentDocs.ts | 69 +++- 3 files changed, 436 insertions(+), 401 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index aeb2eab5..32a0246b 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -99,23 +99,23 @@ All intent commands also support the global CLI flags `--json`, `--log-level`, ` | Command | What it does | Input | Output | | --- | --- | --- | --- | | `image generate` | Generate images from text prompts | none | file | -| `preview generate` | Generate a preview thumbnail | files, directories, URLs, base64 | file | -| `image remove-background` | Remove the background from images | files, directories, URLs, base64 | file | -| `image optimize` | Optimize images without quality loss | files, directories, URLs, base64 | file | -| `image resize` | Convert, resize, or watermark images | files, directories, URLs, base64 | file | -| `document convert` | Convert documents into different formats | files, directories, URLs, base64 | file | -| `document optimize` | Reduce PDF file size | files, directories, URLs, base64 | file | -| `document auto-rotate` | Auto-rotate documents to the correct orientation | files, directories, URLs, base64 | file | -| `document thumbs` | Extract thumbnail images from documents | files, directories, URLs, base64 | directory | -| `audio waveform` | Generate waveform images from audio | files, directories, URLs, base64 | file | -| `text speak` | Speak text | files, directories, URLs, base64 | file | -| `video thumbs` | Extract thumbnails from videos | files, directories, URLs, base64 | directory | -| `video encode-hls` | Run builtin/encode-hls-video@latest | files, directories, URLs, base64 | directory | -| `image describe` | Describe images as labels or publishable text fields | files, directories, URLs, base64 | file | -| `markdown pdf` | Render Markdown files as PDFs | files, directories, URLs, base64 | file | -| `markdown docx` | Render Markdown files as DOCX documents | files, directories, URLs, base64 | file | -| `file compress` | Compress files | files, directories, URLs, base64 | file | -| `file decompress` | Decompress archives | files, directories, URLs, base64 | directory | +| `preview generate` | Generate a preview thumbnail | file, dir, URL, base64 | file | +| `image remove-background` | Remove the background from images | file, dir, URL, base64 | file | +| `image optimize` | Optimize images without quality loss | file, dir, URL, base64 | file | +| `image resize` | Convert, resize, or watermark images | file, dir, URL, base64 | file | +| `document convert` | Convert documents into different formats | file, dir, URL, base64 | file | +| `document optimize` | Reduce PDF file size | file, dir, URL, base64 | file | +| `document auto-rotate` | Auto-rotate documents to the correct orientation | file, dir, URL, base64 | file | +| `document thumbs` | Extract thumbnail images from documents | file, dir, URL, base64 | directory | +| `audio waveform` | Generate waveform images from audio | file, dir, URL, base64 | file | +| `text speak` | Speak text | file, dir, URL, base64 | file | +| `video thumbs` | Extract thumbnails from videos | file, dir, URL, base64 | directory | +| `video encode-hls` | Run builtin/encode-hls-video@latest | file, dir, URL, base64 | directory | +| `image describe` | Describe images as labels or publishable text fields | file, dir, URL, base64 | file | +| `markdown pdf` | Render Markdown files as PDFs | file, dir, URL, base64 | file | +| `markdown docx` | Render Markdown files as DOCX documents | file, dir, URL, base64 | file | +| `file compress` | Compress files | file, dir, URL, base64 | file | +| `file decompress` | Decompress archives | file, dir, URL, base64 | directory | > At least one of `--out` or `--print-urls` is required on every intent command. @@ -162,7 +162,6 @@ npx transloadit image generate [options] **Examples** ```bash -# Run the command transloadit image generate --prompt "A red bicycle in a studio" --out output.png ``` @@ -180,7 +179,7 @@ npx transloadit preview generate --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/preview` @@ -189,30 +188,30 @@ npx transloadit preview generate --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | Width of the thumbnail, in pixels. | -| `--height` | `number` | no | `1` | Height of the thumbnail, in pixels. | -| `--resize-strategy` | `string` | no | `crop` | 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. See the list of available [resize strategies](/docs/topics/resize-strategies/) for more details. | -| `--background` | `string` | no | `value` | 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` | `json` | no | `value` | 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. For 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. The parameter defaults to the following definition: ```json { "audio": ["artwork", "waveform", "icon"], "video": ["artwork", "frame", "icon"], "document": ["page", "icon"], "image": ["image", "icon"], "webpage": ["render", "icon"], "archive": ["icon"], "unknown": ["icon"] } ``` | -| `--artwork-outer-color` | `string` | no | `value` | The color used in the outer parts of the artwork's gradient. | -| `--artwork-center-color` | `string` | no | `value` | The color used in the center of the artwork's gradient. | -| `--waveform-center-color` | `string` | no | `value` | 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. | -| `--waveform-outer-color` | `string` | no | `value` | 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. | -| `--waveform-height` | `number` | no | `1` | 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. | -| `--waveform-width` | `number` | no | `1` | 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. | -| `--icon-style` | `string` | no | `square` | 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:

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

`square` style:
![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png) | -| `--icon-text-color` | `string` | no | `value` | The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied. | -| `--icon-text-font` | `string` | no | `value` | 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. | -| `--icon-text-content` | `string` | no | `extension` | 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` | `boolean` | no | `true` | 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/). | -| `--optimize-priority` | `string` | no | `compression-ratio` | 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-progressive` | `boolean` | no | `true` | 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. | -| `--clip-format` | `string` | no | `apng` | The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied. Please 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. | -| `--clip-offset` | `number` | no | `1` | 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. | -| `--clip-duration` | `number` | no | `1` | 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. | -| `--clip-framerate` | `number` | no | `1` | 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. | -| `--clip-loop` | `boolean` | no | `true` | 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. | +| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | Width of the thumbnail, in pixels. | +| `--height` | `number` | no | `1` | Height of the thumbnail, in pixels. | +| `--resize-strategy` | `string` | no | `crop` | To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. | +| `--background` | `string` | no | `value` | The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). | +| `--strategy` | `json` | no | `value` | Definition of the thumbnail generation process per file category. | +| `--artwork-outer-color` | `string` | no | `value` | The color used in the outer parts of the artwork's gradient. | +| `--artwork-center-color` | `string` | no | `value` | The color used in the center of the artwork's gradient. | +| `--waveform-center-color` | `string` | no | `value` | 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. | +| `--waveform-outer-color` | `string` | no | `value` | 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. | +| `--waveform-height` | `number` | no | `1` | 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… | +| `--waveform-width` | `number` | no | `1` | 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… | +| `--icon-style` | `string` | no | `square` | The style of the icon generated if the icon strategy is applied. | +| `--icon-text-color` | `string` | no | `value` | The color of the text used in the icon. The format is #rrggbb[aa]. Only used if the icon strategy is applied. | +| `--icon-text-font` | `string` | no | `value` | The font family of the text used in the icon. Only used if the icon strategy is applied. Here is a list of all supported fonts. | +| `--icon-text-content` | `string` | no | `extension` | 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)… | +| `--optimize` | `boolean` | no | `true` | Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. | +| `--optimize-priority` | `string` | no | `compression-ratio` | Specifies whether conversion speed or compression ratio is prioritized when optimizing images. | +| `--optimize-progressive` | `boolean` | no | `true` | Specifies whether images should be interlaced, which makes the result image load progressively in browsers. | +| `--clip-format` | `string` | no | `apng` | The animated image format for the generated video clip. Only used if the clip strategy for video files is applied. Please consult the MDN Web Docs for detailed information about… | +| `--clip-offset` | `number` | no | `1` | 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… | +| `--clip-duration` | `number` | no | `1` | 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… | +| `--clip-framerate` | `number` | no | `1` | 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… | +| `--clip-loop` | `boolean` | no | `true` | Specifies whether the generated animated image should loop forever (true) or stop after playing the animation once (false). | **Input & output flags** @@ -237,7 +236,6 @@ npx transloadit preview generate --input [options] **Examples** ```bash -# Run the command transloadit preview generate --input input.file --out output.file ``` @@ -255,7 +253,7 @@ npx transloadit image remove-background --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/bgremove` @@ -292,7 +290,6 @@ npx transloadit image remove-background --input [options] **Examples** ```bash -# Run the command transloadit image remove-background --input input.png --out output.png ``` @@ -310,7 +307,7 @@ npx transloadit image optimize --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/optimize` @@ -319,10 +316,10 @@ npx transloadit image optimize --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--priority` | `string` | no | `compression-ratio` | 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` | `boolean` | no | `true` | 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. | -| `--preserve-meta-data` | `boolean` | no | `true` | 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. | -| `--fix-breaking-images` | `boolean` | no | `true` | 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. | +| `--priority` | `string` | no | `compression-ratio` | Provides different algorithms for better or worse compression for your images, but that run slower or faster. | +| `--progressive` | `boolean` | no | `true` | Interlaces the image if set to true, which makes the result image load progressively in browsers. | +| `--preserve-meta-data` | `boolean` | no | `true` | Specifies if the image's metadata should be preserved during the optimization, or not. | +| `--fix-breaking-images` | `boolean` | no | `true` | If set to true this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies . | **Input & output flags** @@ -347,7 +344,6 @@ npx transloadit image optimize --input [options] **Examples** ```bash -# Run the command transloadit image optimize --input input.png --out output.png ``` @@ -365,7 +361,7 @@ npx transloadit image resize --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/resize` @@ -374,51 +370,51 @@ npx transloadit image resize --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | no | `value` | The output format for the modified image. Some 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/). If `null` (default), then the input image's format will be used as the output format. If you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead. | -| `--width` | `number` | no | `1` | Width of the result in pixels. If not specified, will default to the width of the original. | -| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image. | -| `--resize-strategy` | `string` | no | `crop` | See the list of available [resize strategies](/docs/topics/resize-strategies/). | -| `--zoom` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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. For example: ```json { "x1": 80, "y1": 100, "x2": "60%", "y2": "80%" } ``` This 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. You can also use a JSON string of such an object with coordinates in similar fashion: ```json "{\"x1\": , \"y1\": , \"x2\": , \"y2\": }" ``` To crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/). | -| `--gravity` | `string` | no | `bottom` | The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined. | -| `--strip` | `boolean` | no | `true` | Strips all metadata from the image. This is useful to keep thumbnails as small as possible. | -| `--alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image. | -| `--preclip-alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`. | -| `--flatten` | `boolean` | no | `true` | 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). To preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter. | -| `--correct-gamma` | `boolean` | no | `true` | Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html). | -| `--quality` | `number` | no | `1` | Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | -| `--adaptive-filtering` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | 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). **Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`. | -| `--frame` | `number` | no | `1` | 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` | `string` | no | `CMY` | 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` | `string` | no | `Bilevel` | 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` | `number` | no | `1` | Applies a sepia tone effect in percent. | -| `--rotation` | `auto` | no | `auto` | 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` | `string` | no | `BZip` | Specifies pixel compression for when the image is written. Compression is disabled by default. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | -| `--blur` | `string` | no | `value` | 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. | -| `--blur-regions` | `json` | no | `value` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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. | -| `--watermark-url` | `string` | no | `value` | 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. | -| `--watermark-position` | `string[]` | no | `bottom` | 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"`. An 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" ]`. This 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. | -| `--watermark-x-offset` | `number` | no | `1` | 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`. Values 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. | -| `--watermark-y-offset` | `number` | no | `1` | 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`. Values 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. | -| `--watermark-size` | `string` | no | `value` | The size of the watermark, as a percentage. For 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. | -| `--watermark-resize-strategy` | `string` | no | `area` | Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`. To 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%"`. For 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). For 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). For 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. For 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. | -| `--watermark-opacity` | `number` | no | `1` | The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque. For 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. | -| `--watermark-repeat-x` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated horizontally across the entire width of the image. This is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark. | -| `--watermark-repeat-y` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated vertically across the entire height of the image. This 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` | `json` | no | `value` | 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: ```json "watermarked": { "use": "resized", "robot": "/image/resize", "text": [ { "text": "© 2018 Transloadit.com", "size": 12, "font": "Ubuntu", "color": "#eeeeee", "valign": "bottom", "align": "right", "x_offset": 16, "y_offset": -10 } ] } ``` | -| `--progressive` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | Make this color transparent within the image. Example: `"255,255,255"`. | -| `--trim-whitespace` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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` | `boolean` | no | `true` | Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping. | -| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image is unsharp, please try increasing density. | -| `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | -| `--shave` | `auto` | no | `value` | 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. | +| `--format` | `string` | no | `value` | The output format for the modified image. Some of the most important available formats are "jpg", "png", "gif", and "tiff". For a complete lists of all formats that we can write… | +| `--width` | `number` | no | `1` | Width of the result in pixels. If not specified, will default to the width of the original. | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image. | +| `--resize-strategy` | `string` | no | `crop` | See the list of available resize strategies. | +| `--zoom` | `boolean` | no | `true` | If this is set to false, smaller images will not be stretched to the desired width and height. | +| `--crop` | `auto` | no | `value` | Specify an object containing coordinates for the top left and bottom right corners of the rectangle to be cropped from the original image(s). | +| `--gravity` | `string` | no | `bottom` | The direction from which the image is to be cropped, when "resize_strategy" is set to "crop", but no crop coordinates are defined. | +| `--strip` | `boolean` | no | `true` | Strips all metadata from the image. This is useful to keep thumbnails as small as possible. | +| `--alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image. | +| `--preclip-alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image before applying the clipping path via clip: true. | +| `--flatten` | `boolean` | no | `true` | Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the ImageMagick documentation. | +| `--correct-gamma` | `boolean` | no | `true` | Prevents gamma errors common in many image scaling algorithms. | +| `--quality` | `number` | no | `1` | Controls the image compression for JPG and PNG images. Please also take a look at 🤖/image/optimize. | +| `--adaptive-filtering` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | Either the hexadecimal code or name of the color used to fill the background (used for the pad resize strategy). | +| `--frame` | `number` | no | `1` | Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. | +| `--colorspace` | `string` | no | `CMY` | Sets the image colorspace. For details about the available values, see the ImageMagick documentation. Please note that if you were using "RGB", we recommend using "sRGB" instead… | +| `--type` | `string` | no | `Bilevel` | Sets the image color type. For details about the available values, see the ImageMagick documentation. If you're using colorspace, ImageMagick might try to find the most efficient… | +| `--sepia` | `number` | no | `1` | Applies a sepia tone effect in percent. | +| `--rotation` | `auto` | no | `auto` | 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… | +| `--compress` | `string` | no | `BZip` | Specifies pixel compression for when the image is written. Compression is disabled by default. Please also take a look at 🤖/image/optimize. | +| `--blur` | `string` | no | `value` | Specifies gaussian blur, using a value with the form {radius}x{sigma}. | +| `--blur-regions` | `json` | no | `value` | Specifies an array of ellipse objects that should be blurred on the image. | +| `--brightness` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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… | +| `--watermark-url` | `string` | no | `value` | A URL indicating a PNG image to be overlaid above this image. | +| `--watermark-position` | `string[]` | no | `bottom` | 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". An… | +| `--watermark-x-offset` | `number` | no | `1` | 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. | +| `--watermark-y-offset` | `number` | no | `1` | 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. | +| `--watermark-size` | `string` | no | `value` | The size of the watermark, as a percentage. For 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… | +| `--watermark-resize-strategy` | `string` | no | `area` | Available values are "fit", "min_fit", "stretch" and "area". | +| `--watermark-opacity` | `number` | no | `1` | The opacity of the watermark, where 0.0 is fully transparent and 1.0 is fully opaque. | +| `--watermark-repeat-x` | `boolean` | no | `true` | When set to true, the watermark will be repeated horizontally across the entire width of the image. | +| `--watermark-repeat-y` | `boolean` | no | `true` | When set to true, the watermark will be repeated vertically across the entire height of the image. | +| `--text` | `json` | no | `value` | 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… | +| `--progressive` | `boolean` | no | `true` | Interlaces the image if set to true, which makes the image load progressively in browsers. | +| `--transparent` | `string` | no | `transparent` | Make this color transparent within the image. Example: "255,255,255". | +| `--trim-whitespace` | `boolean` | no | `true` | This determines if additional whitespace around the image should first be trimmed away. | +| `--clip` | `auto` | no | `value` | Apply the clipping path to other operations in the resize job, if one is present. | +| `--negate` | `boolean` | no | `true` | Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping. | +| `--density` | `string` | no | `value` | 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. | +| `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | +| `--shave` | `auto` | no | `value` | Shave pixels from the image edges. The value should be in the format width or widthxheight to specify the number of pixels to remove from each side. | **Input & output flags** @@ -443,7 +439,6 @@ npx transloadit image resize --input [options] **Examples** ```bash -# Run the command transloadit image resize --input input.png --out output.png ``` @@ -461,7 +456,7 @@ npx transloadit document convert --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/convert` @@ -470,15 +465,15 @@ npx transloadit document convert --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | yes | `pdf` | The desired format for document conversion. | -| `--markdown-format` | `string` | no | `commonmark` | 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. | -| `--markdown-theme` | `string` | no | `bare` | This parameter overhauls your Markdown files styling based on several canned presets. | -| `--pdf-margin` | `string` | no | `value` | PDF Paper margins, separated by `,` and with units. We support the following unit values: `px`, `in`, `cm`, `mm`. Currently this parameter is only supported when converting from `html`. | -| `--pdf-print-background` | `boolean` | no | `true` | Print PDF background graphics. Currently this parameter is only supported when converting from `html`. | -| `--pdf-format` | `string` | no | `A0` | PDF paper format. Currently this parameter is only supported when converting from `html`. | -| `--pdf-display-header-footer` | `boolean` | no | `true` | Display PDF header and footer. Currently this parameter is only supported when converting from `html`. | -| `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - `date` formatted print date - `title` document title - `url` document location - `pageNumber` current page number - `totalPages` total pages in the document Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | -| `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the `pdf_header_template`. Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | +| `--format` | `string` | yes | `pdf` | The desired format for document conversion. | +| `--markdown-format` | `string` | no | `commonmark` | Markdown can be represented in several variants, so when using this Robot to transform Markdown into HTML please specify which revision is being used. | +| `--markdown-theme` | `string` | no | `bare` | This parameter overhauls your Markdown files styling based on several canned presets. | +| `--pdf-margin` | `string` | no | `value` | PDF Paper margins, separated by , and with units. We support the following unit values: px, in, cm, mm. Currently this parameter is only supported when converting from html. | +| `--pdf-print-background` | `boolean` | no | `true` | Print PDF background graphics. Currently this parameter is only supported when converting from html. | +| `--pdf-format` | `string` | no | `A0` | PDF paper format. Currently this parameter is only supported when converting from html. | +| `--pdf-display-header-footer` | `boolean` | no | `true` | Display PDF header and footer. Currently this parameter is only supported when converting from html. | +| `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - date formatted print date - title document… | +| `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the pdf_header_template. Currently this parameter is only supported when converting from html, and requires… | **Input & output flags** @@ -503,7 +498,6 @@ npx transloadit document convert --input [options] **Examples** ```bash -# Run the command transloadit document convert --input input.pdf --format pdf --out output.pdf ``` @@ -521,7 +515,7 @@ npx transloadit document optimize --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/optimize` @@ -530,13 +524,13 @@ npx transloadit document optimize --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--preset` | `string` | no | `screen` | The quality preset to use for optimization. Each preset provides a different balance between file size and quality: - `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI. - `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI. - `printer` - High quality suitable for printing. Images are kept at 300 DPI. - `prepress` - Highest quality for professional printing. Minimal compression applied. | -| `--image-dpi` | `number` | no | `1` | Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset. Higher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed. Common values: - 72 - Screen viewing - 150 - eBooks and general documents - 300 - Print quality - 600 - High-quality print | -| `--compress-fonts` | `boolean` | no | `true` | Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size. | -| `--subset-fonts` | `boolean` | no | `true` | 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. | -| `--remove-metadata` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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` | `string` | no | `1.4` | 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. - `1.4` - Acrobat 5 compatibility, most widely supported - `1.5` - Acrobat 6 compatibility - `1.6` - Acrobat 7 compatibility - `1.7` - Acrobat 8+ compatibility (default) - `2.0` - PDF 2.0 standard | +| `--preset` | `string` | no | `screen` | The quality preset to use for optimization. Each preset provides a different balance between file size and quality: - screen - Lowest quality, smallest file size. Best for screen… | +| `--image-dpi` | `number` | no | `1` | Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset. Higher DPI values result in better image quality but larger file… | +| `--compress-fonts` | `boolean` | no | `true` | Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size. | +| `--subset-fonts` | `boolean` | no | `true` | Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. | +| `--remove-metadata` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | Whether to linearize (optimize for Fast Web View) the output PDF. | +| `--compatibility` | `string` | no | `1.4` | 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… | **Input & output flags** @@ -561,7 +555,6 @@ npx transloadit document optimize --input [options] **Examples** ```bash -# Run the command transloadit document optimize --input input.pdf --out output.pdf ``` @@ -579,7 +572,7 @@ npx transloadit document auto-rotate --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/autorotate` @@ -607,7 +600,6 @@ npx transloadit document auto-rotate --input [options] **Examples** ```bash -# Run the command transloadit document auto-rotate --input input.pdf --out output.pdf ``` @@ -625,7 +617,7 @@ npx transloadit document thumbs --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/thumbs` @@ -634,20 +626,20 @@ npx transloadit document thumbs --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--page` | `number` | no | `1` | 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` | `string` | no | `jpg` | The format of the extracted image(s). If 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` | `number` | no | `1` | 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. If your output format is not `"gif"`, then this parameter does not have any effect. | -| `--width` | `number` | no | `1` | Width of the new image, in pixels. If not specified, will default to the width of the input image | -| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image | -| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | -| `--background` | `string` | no | `value` | 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). By 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` | `string` | no | `Remove` | Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency. For a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha). | -| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image has a low resolution, please try using the density parameter to resolve that. | -| `--antialiasing` | `boolean` | no | `true` | Controls whether or not antialiasing is used to remove jagged edges from text or images in a document. | -| `--colorspace` | `string` | no | `CMY` | 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"`. 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. | -| `--trim-whitespace` | `boolean` | no | `true` | 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. If you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`. | -| `--pdf-use-cropbox` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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. Also, 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. Turbo 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. | +| `--page` | `number` | no | `1` | 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` | `string` | no | `jpg` | The format of the extracted image(s). If you specify the value "gif", then an animated gif cycling through all pages is created. Please check out this demo to learn more about… | +| `--delay` | `number` | no | `1` | 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. | +| `--width` | `number` | no | `1` | Width of the new image, in pixels. If not specified, will default to the width of the input image | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image | +| `--resize-strategy` | `string` | no | `crop` | One of the available resize strategies. | +| `--background` | `string` | no | `value` | Either the hexadecimal code or name of the color used to fill the background (only used for the pad resize strategy). | +| `--alpha` | `string` | no | `Remove` | Change how the alpha channel of the resulting image should work. | +| `--density` | `string` | no | `value` | 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. | +| `--antialiasing` | `boolean` | no | `true` | Controls whether or not antialiasing is used to remove jagged edges from text or images in a document. | +| `--colorspace` | `string` | no | `CMY` | Sets the image colorspace. For details about the available values, see the ImageMagick documentation. Please note that if you were using "RGB", we recommend using "sRGB".… | +| `--trim-whitespace` | `boolean` | no | `true` | This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. | +| `--pdf-use-cropbox` | `boolean` | no | `true` | 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… | +| `--turbo` | `boolean` | no | `true` | If you set this to false, the robot will not emit files as they become available. | **Input & output flags** @@ -672,7 +664,6 @@ npx transloadit document thumbs --input [options] **Examples** ```bash -# Run the command transloadit document thumbs --input input.pdf --out output/ ``` @@ -690,7 +681,7 @@ npx transloadit audio waveform --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/audio/waveform` @@ -699,32 +690,32 @@ npx transloadit audio waveform --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--ffmpeg` | `json` | no | `value` | 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` | `string` | no | `image` | 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` | `number` | no | `1` | The width of the resulting image if the format `"image"` was selected. | -| `--height` | `number` | no | `1` | The height of the resulting image if the format `"image"` was selected. | -| `--antialiasing` | `auto` | no | `0` | 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. | -| `--background-color` | `string` | no | `value` | The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected. | -| `--center-color` | `string` | no | `value` | The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | -| `--outer-color` | `string` | no | `value` | The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | -| `--style` | `string` | no | `v0` | Waveform style version. - `"v0"`: Legacy waveform generation (default). - `"v1"`: Advanced waveform generation with additional parameters. For backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2). | -| `--split-channels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel. | -| `--zoom` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`. | -| `--pixels-per-second` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`. | -| `--bits` | `number` | no | `8` | Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16. | -| `--start` | `number` | no | `1` | Available when style is `"v1"`. Start time in seconds. | -| `--end` | `number` | no | `1` | Available when style is `"v1"`. End time in seconds (0 means end of audio). | -| `--colors` | `string` | no | `audition` | Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity". | -| `--border-color` | `string` | no | `value` | Available when style is `"v1"`. Border color in "rrggbbaa" format. | -| `--waveform-style` | `string` | no | `normal` | Available when style is `"v1"`. Waveform style. Can be "normal" or "bars". | -| `--bar-width` | `number` | no | `1` | Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars". | -| `--bar-gap` | `number` | no | `1` | Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars". | -| `--bar-style` | `string` | no | `square` | Available when style is `"v1"`. Bar style when waveform_style is "bars". | -| `--axis-label-color` | `string` | no | `value` | Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format. | -| `--no-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels. | -| `--with-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels. | -| `--amplitude-scale` | `number` | no | `1` | Available when style is `"v1"`. Amplitude scale factor. | -| `--compression` | `number` | no | `1` | Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | +| `--ffmpeg` | `json` | no | `value` | 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… | +| `--format` | `string` | no | `image` | 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` | `number` | no | `1` | The width of the resulting image if the format "image" was selected. | +| `--height` | `number` | no | `1` | The height of the resulting image if the format "image" was selected. | +| `--antialiasing` | `auto` | no | `0` | 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. | +| `--background-color` | `string` | no | `value` | The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format "image" was selected. | +| `--center-color` | `string` | no | `value` | The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--outer-color` | `string` | no | `value` | The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--style` | `string` | no | `v0` | Waveform style version. - "v0": Legacy waveform generation (default). - "v1": Advanced waveform generation with additional parameters. For backwards compatibility, numeric values… | +| `--split-channels` | `boolean` | no | `true` | Available when style is "v1". If set to true, outputs multi-channel waveform data or image files, one per channel. | +| `--zoom` | `number` | no | `1` | Available when style is "v1". Zoom level in samples per pixel. This parameter cannot be used together with pixels_per_second. | +| `--pixels-per-second` | `number` | no | `1` | Available when style is "v1". Zoom level in pixels per second. This parameter cannot be used together with zoom. | +| `--bits` | `number` | no | `8` | Available when style is "v1". Bit depth for waveform data. Can be 8 or 16. | +| `--start` | `number` | no | `1` | Available when style is "v1". Start time in seconds. | +| `--end` | `number` | no | `1` | Available when style is "v1". End time in seconds (0 means end of audio). | +| `--colors` | `string` | no | `audition` | Available when style is "v1". Color scheme to use. Can be "audition" or "audacity". | +| `--border-color` | `string` | no | `value` | Available when style is "v1". Border color in "rrggbbaa" format. | +| `--waveform-style` | `string` | no | `normal` | Available when style is "v1". Waveform style. Can be "normal" or "bars". | +| `--bar-width` | `number` | no | `1` | Available when style is "v1". Width of bars in pixels when waveform_style is "bars". | +| `--bar-gap` | `number` | no | `1` | Available when style is "v1". Gap between bars in pixels when waveform_style is "bars". | +| `--bar-style` | `string` | no | `square` | Available when style is "v1". Bar style when waveform_style is "bars". | +| `--axis-label-color` | `string` | no | `value` | Available when style is "v1". Color for axis labels in "rrggbbaa" format. | +| `--no-axis-labels` | `boolean` | no | `true` | Available when style is "v1". If set to true, renders waveform image without axis labels. | +| `--with-axis-labels` | `boolean` | no | `true` | Available when style is "v1". If set to true, renders waveform image with axis labels. | +| `--amplitude-scale` | `number` | no | `1` | Available when style is "v1". Amplitude scale factor. | +| `--compression` | `number` | no | `1` | Available when style is "v1". PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | **Input & output flags** @@ -749,7 +740,6 @@ npx transloadit audio waveform --input [options] **Examples** ```bash -# Run the command transloadit audio waveform --input input.mp3 --out output.png ``` @@ -767,7 +757,7 @@ npx transloadit text speak --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/text/speak` @@ -776,11 +766,11 @@ npx transloadit text speak --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--prompt` | `string` | no | `"A red bicycle in a studio"` | Which text to speak. You can also set this to `null` and supply an input text file. | -| `--provider` | `string` | yes | `aws` | Which AI provider to leverage. Transloadit 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. | -| `--target-language` | `string` | no | `en-US` | The written language of the document. This will also be the language of the spoken text. The 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` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | -| `--ssml` | `boolean` | no | `true` | 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. Please 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). | +| `--prompt` | `string` | no | `"A red bicycle in a studio"` | Which text to speak. You can also set this to null and supply an input text file. | +| `--provider` | `string` | yes | `aws` | Which AI provider to leverage. Transloadit outsources this task and abstracts the interface so you can expect the same data structures, but different latencies and information… | +| `--target-language` | `string` | no | `en-US` | The written language of the document. This will also be the language of the spoken text. The language should be specified in the BCP-47 format, such as "en-GB", "de-DE" or… | +| `--voice` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | +| `--ssml` | `boolean` | no | `true` | Supply Speech Synthesis Markup Language instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations. | **Input & output flags** @@ -805,7 +795,6 @@ npx transloadit text speak --input [options] **Examples** ```bash -# Run the command transloadit text speak --input input.pdf --provider aws --out output.mp3 ``` @@ -823,7 +812,7 @@ npx transloadit video thumbs --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/video/thumbs` @@ -832,16 +821,16 @@ npx transloadit video thumbs --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--ffmpeg` | `json` | no | `value` | 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` | `number` | no | `1` | 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. The 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. To extract thumbnails for specific timestamps, use the `offsets` parameter. | -| `--offsets` | `auto` | no | `value` | 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%" ]`. This option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored. | -| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | The width of the thumbnail, in pixels. Defaults to the original width of the video. | -| `--height` | `number` | no | `1` | The height of the thumbnail, in pixels. Defaults to the original height of the video. | -| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | -| `--background` | `string` | no | `value` | 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` | `number` | no | `0` | 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. | -| `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | +| `--ffmpeg` | `json` | no | `value` | 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… | +| `--count` | `number` | no | `1` | 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… | +| `--offsets` | `auto` | no | `value` | An array of offsets representing seconds of the file duration, such as [ 2, 45, 120 ]. | +| `--format` | `string` | no | `jpg` | 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… | +| `--width` | `number` | no | `1` | The width of the thumbnail, in pixels. Defaults to the original width of the video. | +| `--height` | `number` | no | `1` | The height of the thumbnail, in pixels. Defaults to the original height of the video. | +| `--resize-strategy` | `string` | no | `crop` | One of the available resize strategies. | +| `--background` | `string` | no | `value` | 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` | `number` | no | `0` | Forces the video to be rotated by the specified degree integer. | +| `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | **Input & output flags** @@ -866,7 +855,6 @@ npx transloadit video thumbs --input [options] **Examples** ```bash -# Run the command transloadit video thumbs --input input.mp4 --out output/ ``` @@ -884,7 +872,7 @@ npx transloadit video encode-hls --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `builtin/encode-hls-video@latest` @@ -912,7 +900,6 @@ npx transloadit video encode-hls --input [options] **Examples** ```bash -# Run the command transloadit video encode-hls --input input.mp4 --out output/ ``` @@ -930,7 +917,7 @@ npx transloadit image describe --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--watch` - Backend: semantic alias `image-describe` @@ -987,7 +974,7 @@ npx transloadit markdown pdf --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-pdf` @@ -1041,7 +1028,7 @@ npx transloadit markdown docx --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-docx` @@ -1095,7 +1082,7 @@ npx transloadit file compress --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: single assembly - Backend: `/file/compress` @@ -1104,12 +1091,12 @@ npx transloadit file compress --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | no | `zip` | The format of the archive to be created. Supported values are `"tar"` and `"zip"`. Note that `"tar"` without setting `gzip` to `true` results in an archive that's not compressed in any way. | -| `--gzip` | `boolean` | no | `true` | Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format. | -| `--password` | `string` | no | `value` | 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. This parameter has no effect if the format parameter is anything other than `"zip"`. | -| `--compression-level` | `number` | no | `1` | 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. If 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. | -| `--file-layout` | `string` | no | `advanced` | 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. Files with same names are numbered in the `"simple"` file layout to avoid naming collisions. | -| `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | +| `--format` | `string` | no | `zip` | The format of the archive to be created. Supported values are "tar" and "zip". Note that "tar" without setting gzip to true results in an archive that's not compressed in any way. | +| `--gzip` | `boolean` | no | `true` | Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the "tar" format. | +| `--password` | `string` | no | `value` | This allows you to encrypt all archive contents with a password and thereby protect it against unauthorized use. | +| `--compression-level` | `number` | no | `1` | 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… | +| `--file-layout` | `string` | no | `advanced` | 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… | +| `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | **Input & output flags** @@ -1131,7 +1118,6 @@ npx transloadit file compress --input [options] **Examples** ```bash -# Run the command transloadit file compress --input input.file --out output.file ``` @@ -1149,7 +1135,7 @@ npx transloadit file decompress --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/decompress` @@ -1177,7 +1163,6 @@ npx transloadit file decompress --input [options] **Examples** ```bash -# Run the command transloadit file decompress --input input.file --out output/ ``` @@ -1959,3 +1944,5 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo See [CONTRIBUTING](./CONTRIBUTING.md). + + diff --git a/packages/node/docs/intent-commands.md b/packages/node/docs/intent-commands.md index 019ba572..b8d68606 100644 --- a/packages/node/docs/intent-commands.md +++ b/packages/node/docs/intent-commands.md @@ -11,23 +11,23 @@ All intent commands also support the global CLI flags `--json`, `--log-level`, ` | Command | What it does | Input | Output | | --- | --- | --- | --- | | `image generate` | Generate images from text prompts | none | file | -| `preview generate` | Generate a preview thumbnail | files, directories, URLs, base64 | file | -| `image remove-background` | Remove the background from images | files, directories, URLs, base64 | file | -| `image optimize` | Optimize images without quality loss | files, directories, URLs, base64 | file | -| `image resize` | Convert, resize, or watermark images | files, directories, URLs, base64 | file | -| `document convert` | Convert documents into different formats | files, directories, URLs, base64 | file | -| `document optimize` | Reduce PDF file size | files, directories, URLs, base64 | file | -| `document auto-rotate` | Auto-rotate documents to the correct orientation | files, directories, URLs, base64 | file | -| `document thumbs` | Extract thumbnail images from documents | files, directories, URLs, base64 | directory | -| `audio waveform` | Generate waveform images from audio | files, directories, URLs, base64 | file | -| `text speak` | Speak text | files, directories, URLs, base64 | file | -| `video thumbs` | Extract thumbnails from videos | files, directories, URLs, base64 | directory | -| `video encode-hls` | Run builtin/encode-hls-video@latest | files, directories, URLs, base64 | directory | -| `image describe` | Describe images as labels or publishable text fields | files, directories, URLs, base64 | file | -| `markdown pdf` | Render Markdown files as PDFs | files, directories, URLs, base64 | file | -| `markdown docx` | Render Markdown files as DOCX documents | files, directories, URLs, base64 | file | -| `file compress` | Compress files | files, directories, URLs, base64 | file | -| `file decompress` | Decompress archives | files, directories, URLs, base64 | directory | +| `preview generate` | Generate a preview thumbnail | file, dir, URL, base64 | file | +| `image remove-background` | Remove the background from images | file, dir, URL, base64 | file | +| `image optimize` | Optimize images without quality loss | file, dir, URL, base64 | file | +| `image resize` | Convert, resize, or watermark images | file, dir, URL, base64 | file | +| `document convert` | Convert documents into different formats | file, dir, URL, base64 | file | +| `document optimize` | Reduce PDF file size | file, dir, URL, base64 | file | +| `document auto-rotate` | Auto-rotate documents to the correct orientation | file, dir, URL, base64 | file | +| `document thumbs` | Extract thumbnail images from documents | file, dir, URL, base64 | directory | +| `audio waveform` | Generate waveform images from audio | file, dir, URL, base64 | file | +| `text speak` | Speak text | file, dir, URL, base64 | file | +| `video thumbs` | Extract thumbnails from videos | file, dir, URL, base64 | directory | +| `video encode-hls` | Run builtin/encode-hls-video@latest | file, dir, URL, base64 | directory | +| `image describe` | Describe images as labels or publishable text fields | file, dir, URL, base64 | file | +| `markdown pdf` | Render Markdown files as PDFs | file, dir, URL, base64 | file | +| `markdown docx` | Render Markdown files as DOCX documents | file, dir, URL, base64 | file | +| `file compress` | Compress files | file, dir, URL, base64 | file | +| `file decompress` | Decompress archives | file, dir, URL, base64 | directory | > At least one of `--out` or `--print-urls` is required on every intent command. @@ -74,7 +74,6 @@ npx transloadit image generate [options] **Examples** ```bash -# Run the command transloadit image generate --prompt "A red bicycle in a studio" --out output.png ``` @@ -92,7 +91,7 @@ npx transloadit preview generate --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/preview` @@ -101,30 +100,30 @@ npx transloadit preview generate --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | Width of the thumbnail, in pixels. | -| `--height` | `number` | no | `1` | Height of the thumbnail, in pixels. | -| `--resize-strategy` | `string` | no | `crop` | 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. See the list of available [resize strategies](/docs/topics/resize-strategies/) for more details. | -| `--background` | `string` | no | `value` | 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` | `json` | no | `value` | 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. For 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. The parameter defaults to the following definition: ```json { "audio": ["artwork", "waveform", "icon"], "video": ["artwork", "frame", "icon"], "document": ["page", "icon"], "image": ["image", "icon"], "webpage": ["render", "icon"], "archive": ["icon"], "unknown": ["icon"] } ``` | -| `--artwork-outer-color` | `string` | no | `value` | The color used in the outer parts of the artwork's gradient. | -| `--artwork-center-color` | `string` | no | `value` | The color used in the center of the artwork's gradient. | -| `--waveform-center-color` | `string` | no | `value` | 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. | -| `--waveform-outer-color` | `string` | no | `value` | 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. | -| `--waveform-height` | `number` | no | `1` | 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. | -| `--waveform-width` | `number` | no | `1` | 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. | -| `--icon-style` | `string` | no | `square` | 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:

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

`square` style:
![Image with square style]({{site.asset_cdn}}/assets/images/file-preview/icon-square.png) | -| `--icon-text-color` | `string` | no | `value` | The color of the text used in the icon. The format is `#rrggbb[aa]`. Only used if the `icon` strategy is applied. | -| `--icon-text-font` | `string` | no | `value` | 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. | -| `--icon-text-content` | `string` | no | `extension` | 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` | `boolean` | no | `true` | 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/). | -| `--optimize-priority` | `string` | no | `compression-ratio` | 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-progressive` | `boolean` | no | `true` | 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. | -| `--clip-format` | `string` | no | `apng` | The animated image format for the generated video clip. Only used if the `clip` strategy for video files is applied. Please 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. | -| `--clip-offset` | `number` | no | `1` | 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. | -| `--clip-duration` | `number` | no | `1` | 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. | -| `--clip-framerate` | `number` | no | `1` | 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. | -| `--clip-loop` | `boolean` | no | `true` | 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. | +| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | Width of the thumbnail, in pixels. | +| `--height` | `number` | no | `1` | Height of the thumbnail, in pixels. | +| `--resize-strategy` | `string` | no | `crop` | To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. | +| `--background` | `string` | no | `value` | The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). | +| `--strategy` | `json` | no | `value` | Definition of the thumbnail generation process per file category. | +| `--artwork-outer-color` | `string` | no | `value` | The color used in the outer parts of the artwork's gradient. | +| `--artwork-center-color` | `string` | no | `value` | The color used in the center of the artwork's gradient. | +| `--waveform-center-color` | `string` | no | `value` | 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. | +| `--waveform-outer-color` | `string` | no | `value` | 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. | +| `--waveform-height` | `number` | no | `1` | 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… | +| `--waveform-width` | `number` | no | `1` | 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… | +| `--icon-style` | `string` | no | `square` | The style of the icon generated if the icon strategy is applied. | +| `--icon-text-color` | `string` | no | `value` | The color of the text used in the icon. The format is #rrggbb[aa]. Only used if the icon strategy is applied. | +| `--icon-text-font` | `string` | no | `value` | The font family of the text used in the icon. Only used if the icon strategy is applied. Here is a list of all supported fonts. | +| `--icon-text-content` | `string` | no | `extension` | 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)… | +| `--optimize` | `boolean` | no | `true` | Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. | +| `--optimize-priority` | `string` | no | `compression-ratio` | Specifies whether conversion speed or compression ratio is prioritized when optimizing images. | +| `--optimize-progressive` | `boolean` | no | `true` | Specifies whether images should be interlaced, which makes the result image load progressively in browsers. | +| `--clip-format` | `string` | no | `apng` | The animated image format for the generated video clip. Only used if the clip strategy for video files is applied. Please consult the MDN Web Docs for detailed information about… | +| `--clip-offset` | `number` | no | `1` | 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… | +| `--clip-duration` | `number` | no | `1` | 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… | +| `--clip-framerate` | `number` | no | `1` | 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… | +| `--clip-loop` | `boolean` | no | `true` | Specifies whether the generated animated image should loop forever (true) or stop after playing the animation once (false). | **Input & output flags** @@ -149,7 +148,6 @@ npx transloadit preview generate --input [options] **Examples** ```bash -# Run the command transloadit preview generate --input input.file --out output.file ``` @@ -167,7 +165,7 @@ npx transloadit image remove-background --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/bgremove` @@ -204,7 +202,6 @@ npx transloadit image remove-background --input [options] **Examples** ```bash -# Run the command transloadit image remove-background --input input.png --out output.png ``` @@ -222,7 +219,7 @@ npx transloadit image optimize --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/optimize` @@ -231,10 +228,10 @@ npx transloadit image optimize --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--priority` | `string` | no | `compression-ratio` | 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` | `boolean` | no | `true` | 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. | -| `--preserve-meta-data` | `boolean` | no | `true` | 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. | -| `--fix-breaking-images` | `boolean` | no | `true` | 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. | +| `--priority` | `string` | no | `compression-ratio` | Provides different algorithms for better or worse compression for your images, but that run slower or faster. | +| `--progressive` | `boolean` | no | `true` | Interlaces the image if set to true, which makes the result image load progressively in browsers. | +| `--preserve-meta-data` | `boolean` | no | `true` | Specifies if the image's metadata should be preserved during the optimization, or not. | +| `--fix-breaking-images` | `boolean` | no | `true` | If set to true this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies . | **Input & output flags** @@ -259,7 +256,6 @@ npx transloadit image optimize --input [options] **Examples** ```bash -# Run the command transloadit image optimize --input input.png --out output.png ``` @@ -277,7 +273,7 @@ npx transloadit image resize --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/resize` @@ -286,51 +282,51 @@ npx transloadit image resize --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | no | `value` | The output format for the modified image. Some 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/). If `null` (default), then the input image's format will be used as the output format. If you wish to convert to `"pdf"`, please consider [🤖/document/convert](/docs/robots/document-convert/) instead. | -| `--width` | `number` | no | `1` | Width of the result in pixels. If not specified, will default to the width of the original. | -| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image. | -| `--resize-strategy` | `string` | no | `crop` | See the list of available [resize strategies](/docs/topics/resize-strategies/). | -| `--zoom` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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. For example: ```json { "x1": 80, "y1": 100, "x2": "60%", "y2": "80%" } ``` This 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. You can also use a JSON string of such an object with coordinates in similar fashion: ```json "{\"x1\": , \"y1\": , \"x2\": , \"y2\": }" ``` To crop around human faces, see [🤖/image/facedetect](/docs/robots/image-facedetect/). | -| `--gravity` | `string` | no | `bottom` | The direction from which the image is to be cropped, when `"resize_strategy"` is set to `"crop"`, but no crop coordinates are defined. | -| `--strip` | `boolean` | no | `true` | Strips all metadata from the image. This is useful to keep thumbnails as small as possible. | -| `--alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image. | -| `--preclip-alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image before applying the clipping path via `clip: true`. | -| `--flatten` | `boolean` | no | `true` | 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). To preserve animations, GIF files are not flattened when this is set to `true`. To flatten GIF animations, use the `frame` parameter. | -| `--correct-gamma` | `boolean` | no | `true` | Prevents gamma errors [common in many image scaling algorithms](https://www.4p8.com/eric.brasseur/gamma.html). | -| `--quality` | `number` | no | `1` | Controls the image compression for JPG and PNG images. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | -| `--adaptive-filtering` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | 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). **Note:** By default, the background of transparent images is changed to white. To preserve transparency, set `"background"` to `"none"`. | -| `--frame` | `number` | no | `1` | 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` | `string` | no | `CMY` | 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` | `string` | no | `Bilevel` | 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` | `number` | no | `1` | Applies a sepia tone effect in percent. | -| `--rotation` | `auto` | no | `auto` | 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` | `string` | no | `BZip` | Specifies pixel compression for when the image is written. Compression is disabled by default. Please also take a look at [🤖/image/optimize](/docs/robots/image-optimize/). | -| `--blur` | `string` | no | `value` | 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. | -| `--blur-regions` | `json` | no | `value` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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. | -| `--watermark-url` | `string` | no | `value` | 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. | -| `--watermark-position` | `string[]` | no | `bottom` | 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"`. An 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" ]`. This 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. | -| `--watermark-x-offset` | `number` | no | `1` | 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`. Values 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. | -| `--watermark-y-offset` | `number` | no | `1` | 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`. Values 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. | -| `--watermark-size` | `string` | no | `value` | The size of the watermark, as a percentage. For 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. | -| `--watermark-resize-strategy` | `string` | no | `area` | Available values are `"fit"`, `"min_fit"`, `"stretch"` and `"area"`. To 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%"`. For 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). For 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). For 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. For 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. | -| `--watermark-opacity` | `number` | no | `1` | The opacity of the watermark, where `0.0` is fully transparent and `1.0` is fully opaque. For 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. | -| `--watermark-repeat-x` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated horizontally across the entire width of the image. This is useful for creating tiled watermark patterns that cover the full image and make it more difficult to crop out the watermark. | -| `--watermark-repeat-y` | `boolean` | no | `true` | When set to `true`, the watermark will be repeated vertically across the entire height of the image. This 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` | `json` | no | `value` | 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: ```json "watermarked": { "use": "resized", "robot": "/image/resize", "text": [ { "text": "© 2018 Transloadit.com", "size": 12, "font": "Ubuntu", "color": "#eeeeee", "valign": "bottom", "align": "right", "x_offset": 16, "y_offset": -10 } ] } ``` | -| `--progressive` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | Make this color transparent within the image. Example: `"255,255,255"`. | -| `--trim-whitespace` | `boolean` | no | `true` | 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` | `auto` | no | `value` | 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` | `boolean` | no | `true` | Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping. | -| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image is unsharp, please try increasing density. | -| `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | -| `--shave` | `auto` | no | `value` | 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. | +| `--format` | `string` | no | `value` | The output format for the modified image. Some of the most important available formats are "jpg", "png", "gif", and "tiff". For a complete lists of all formats that we can write… | +| `--width` | `number` | no | `1` | Width of the result in pixels. If not specified, will default to the width of the original. | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image. | +| `--resize-strategy` | `string` | no | `crop` | See the list of available resize strategies. | +| `--zoom` | `boolean` | no | `true` | If this is set to false, smaller images will not be stretched to the desired width and height. | +| `--crop` | `auto` | no | `value` | Specify an object containing coordinates for the top left and bottom right corners of the rectangle to be cropped from the original image(s). | +| `--gravity` | `string` | no | `bottom` | The direction from which the image is to be cropped, when "resize_strategy" is set to "crop", but no crop coordinates are defined. | +| `--strip` | `boolean` | no | `true` | Strips all metadata from the image. This is useful to keep thumbnails as small as possible. | +| `--alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image. | +| `--preclip-alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image before applying the clipping path via clip: true. | +| `--flatten` | `boolean` | no | `true` | Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the ImageMagick documentation. | +| `--correct-gamma` | `boolean` | no | `true` | Prevents gamma errors common in many image scaling algorithms. | +| `--quality` | `number` | no | `1` | Controls the image compression for JPG and PNG images. Please also take a look at 🤖/image/optimize. | +| `--adaptive-filtering` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | Either the hexadecimal code or name of the color used to fill the background (used for the pad resize strategy). | +| `--frame` | `number` | no | `1` | Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. | +| `--colorspace` | `string` | no | `CMY` | Sets the image colorspace. For details about the available values, see the ImageMagick documentation. Please note that if you were using "RGB", we recommend using "sRGB" instead… | +| `--type` | `string` | no | `Bilevel` | Sets the image color type. For details about the available values, see the ImageMagick documentation. If you're using colorspace, ImageMagick might try to find the most efficient… | +| `--sepia` | `number` | no | `1` | Applies a sepia tone effect in percent. | +| `--rotation` | `auto` | no | `auto` | 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… | +| `--compress` | `string` | no | `BZip` | Specifies pixel compression for when the image is written. Compression is disabled by default. Please also take a look at 🤖/image/optimize. | +| `--blur` | `string` | no | `value` | Specifies gaussian blur, using a value with the form {radius}x{sigma}. | +| `--blur-regions` | `json` | no | `value` | Specifies an array of ellipse objects that should be blurred on the image. | +| `--brightness` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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… | +| `--watermark-url` | `string` | no | `value` | A URL indicating a PNG image to be overlaid above this image. | +| `--watermark-position` | `string[]` | no | `bottom` | 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". An… | +| `--watermark-x-offset` | `number` | no | `1` | 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. | +| `--watermark-y-offset` | `number` | no | `1` | 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. | +| `--watermark-size` | `string` | no | `value` | The size of the watermark, as a percentage. For 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… | +| `--watermark-resize-strategy` | `string` | no | `area` | Available values are "fit", "min_fit", "stretch" and "area". | +| `--watermark-opacity` | `number` | no | `1` | The opacity of the watermark, where 0.0 is fully transparent and 1.0 is fully opaque. | +| `--watermark-repeat-x` | `boolean` | no | `true` | When set to true, the watermark will be repeated horizontally across the entire width of the image. | +| `--watermark-repeat-y` | `boolean` | no | `true` | When set to true, the watermark will be repeated vertically across the entire height of the image. | +| `--text` | `json` | no | `value` | 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… | +| `--progressive` | `boolean` | no | `true` | Interlaces the image if set to true, which makes the image load progressively in browsers. | +| `--transparent` | `string` | no | `transparent` | Make this color transparent within the image. Example: "255,255,255". | +| `--trim-whitespace` | `boolean` | no | `true` | This determines if additional whitespace around the image should first be trimmed away. | +| `--clip` | `auto` | no | `value` | Apply the clipping path to other operations in the resize job, if one is present. | +| `--negate` | `boolean` | no | `true` | Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping. | +| `--density` | `string` | no | `value` | 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. | +| `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | +| `--shave` | `auto` | no | `value` | Shave pixels from the image edges. The value should be in the format width or widthxheight to specify the number of pixels to remove from each side. | **Input & output flags** @@ -355,7 +351,6 @@ npx transloadit image resize --input [options] **Examples** ```bash -# Run the command transloadit image resize --input input.png --out output.png ``` @@ -373,7 +368,7 @@ npx transloadit document convert --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/convert` @@ -382,15 +377,15 @@ npx transloadit document convert --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | yes | `pdf` | The desired format for document conversion. | -| `--markdown-format` | `string` | no | `commonmark` | 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. | -| `--markdown-theme` | `string` | no | `bare` | This parameter overhauls your Markdown files styling based on several canned presets. | -| `--pdf-margin` | `string` | no | `value` | PDF Paper margins, separated by `,` and with units. We support the following unit values: `px`, `in`, `cm`, `mm`. Currently this parameter is only supported when converting from `html`. | -| `--pdf-print-background` | `boolean` | no | `true` | Print PDF background graphics. Currently this parameter is only supported when converting from `html`. | -| `--pdf-format` | `string` | no | `A0` | PDF paper format. Currently this parameter is only supported when converting from `html`. | -| `--pdf-display-header-footer` | `boolean` | no | `true` | Display PDF header and footer. Currently this parameter is only supported when converting from `html`. | -| `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - `date` formatted print date - `title` document title - `url` document location - `pageNumber` current page number - `totalPages` total pages in the document Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | -| `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the `pdf_header_template`. Currently this parameter is only supported when converting from `html`, and requires `pdf_display_header_footer` to be enabled. To 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: ```html
``` | +| `--format` | `string` | yes | `pdf` | The desired format for document conversion. | +| `--markdown-format` | `string` | no | `commonmark` | Markdown can be represented in several variants, so when using this Robot to transform Markdown into HTML please specify which revision is being used. | +| `--markdown-theme` | `string` | no | `bare` | This parameter overhauls your Markdown files styling based on several canned presets. | +| `--pdf-margin` | `string` | no | `value` | PDF Paper margins, separated by , and with units. We support the following unit values: px, in, cm, mm. Currently this parameter is only supported when converting from html. | +| `--pdf-print-background` | `boolean` | no | `true` | Print PDF background graphics. Currently this parameter is only supported when converting from html. | +| `--pdf-format` | `string` | no | `A0` | PDF paper format. Currently this parameter is only supported when converting from html. | +| `--pdf-display-header-footer` | `boolean` | no | `true` | Display PDF header and footer. Currently this parameter is only supported when converting from html. | +| `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - date formatted print date - title document… | +| `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the pdf_header_template. Currently this parameter is only supported when converting from html, and requires… | **Input & output flags** @@ -415,7 +410,6 @@ npx transloadit document convert --input [options] **Examples** ```bash -# Run the command transloadit document convert --input input.pdf --format pdf --out output.pdf ``` @@ -433,7 +427,7 @@ npx transloadit document optimize --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/optimize` @@ -442,13 +436,13 @@ npx transloadit document optimize --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--preset` | `string` | no | `screen` | The quality preset to use for optimization. Each preset provides a different balance between file size and quality: - `screen` - Lowest quality, smallest file size. Best for screen viewing only. Images are downsampled to 72 DPI. - `ebook` - Good balance of quality and size. Suitable for most purposes. Images are downsampled to 150 DPI. - `printer` - High quality suitable for printing. Images are kept at 300 DPI. - `prepress` - Highest quality for professional printing. Minimal compression applied. | -| `--image-dpi` | `number` | no | `1` | Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset. Higher DPI values result in better image quality but larger file sizes. Lower values produce smaller files but may result in pixelated images when printed. Common values: - 72 - Screen viewing - 150 - eBooks and general documents - 300 - Print quality - 600 - High-quality print | -| `--compress-fonts` | `boolean` | no | `true` | Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size. | -| `--subset-fonts` | `boolean` | no | `true` | 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. | -| `--remove-metadata` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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` | `string` | no | `1.4` | 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. - `1.4` - Acrobat 5 compatibility, most widely supported - `1.5` - Acrobat 6 compatibility - `1.6` - Acrobat 7 compatibility - `1.7` - Acrobat 8+ compatibility (default) - `2.0` - PDF 2.0 standard | +| `--preset` | `string` | no | `screen` | The quality preset to use for optimization. Each preset provides a different balance between file size and quality: - screen - Lowest quality, smallest file size. Best for screen… | +| `--image-dpi` | `number` | no | `1` | Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset. Higher DPI values result in better image quality but larger file… | +| `--compress-fonts` | `boolean` | no | `true` | Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size. | +| `--subset-fonts` | `boolean` | no | `true` | Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. | +| `--remove-metadata` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | Whether to linearize (optimize for Fast Web View) the output PDF. | +| `--compatibility` | `string` | no | `1.4` | 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… | **Input & output flags** @@ -473,7 +467,6 @@ npx transloadit document optimize --input [options] **Examples** ```bash -# Run the command transloadit document optimize --input input.pdf --out output.pdf ``` @@ -491,7 +484,7 @@ npx transloadit document auto-rotate --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/autorotate` @@ -519,7 +512,6 @@ npx transloadit document auto-rotate --input [options] **Examples** ```bash -# Run the command transloadit document auto-rotate --input input.pdf --out output.pdf ``` @@ -537,7 +529,7 @@ npx transloadit document thumbs --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/thumbs` @@ -546,20 +538,20 @@ npx transloadit document thumbs --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--page` | `number` | no | `1` | 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` | `string` | no | `jpg` | The format of the extracted image(s). If 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` | `number` | no | `1` | 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. If your output format is not `"gif"`, then this parameter does not have any effect. | -| `--width` | `number` | no | `1` | Width of the new image, in pixels. If not specified, will default to the width of the input image | -| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image | -| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | -| `--background` | `string` | no | `value` | 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). By 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` | `string` | no | `Remove` | Change how the alpha channel of the resulting image should work. Valid values are `"Set"` to enable transparency and `"Remove"` to remove transparency. For a list of all valid values please check the ImageMagick documentation [here](http://www.imagemagick.org/script/command-line-options.php#alpha). | -| `--density` | `string` | no | `value` | 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. You can set this value to a specific `width` or in the format `width`x`height`. If your converted image has a low resolution, please try using the density parameter to resolve that. | -| `--antialiasing` | `boolean` | no | `true` | Controls whether or not antialiasing is used to remove jagged edges from text or images in a document. | -| `--colorspace` | `string` | no | `CMY` | 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"`. 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. | -| `--trim-whitespace` | `boolean` | no | `true` | 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. If you need to reflect the PDF's dimensions in your image, it is generally a good idea to set this to `false`. | -| `--pdf-use-cropbox` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | 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. Also, 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. Turbo 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. | +| `--page` | `number` | no | `1` | 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` | `string` | no | `jpg` | The format of the extracted image(s). If you specify the value "gif", then an animated gif cycling through all pages is created. Please check out this demo to learn more about… | +| `--delay` | `number` | no | `1` | 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. | +| `--width` | `number` | no | `1` | Width of the new image, in pixels. If not specified, will default to the width of the input image | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image | +| `--resize-strategy` | `string` | no | `crop` | One of the available resize strategies. | +| `--background` | `string` | no | `value` | Either the hexadecimal code or name of the color used to fill the background (only used for the pad resize strategy). | +| `--alpha` | `string` | no | `Remove` | Change how the alpha channel of the resulting image should work. | +| `--density` | `string` | no | `value` | 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. | +| `--antialiasing` | `boolean` | no | `true` | Controls whether or not antialiasing is used to remove jagged edges from text or images in a document. | +| `--colorspace` | `string` | no | `CMY` | Sets the image colorspace. For details about the available values, see the ImageMagick documentation. Please note that if you were using "RGB", we recommend using "sRGB".… | +| `--trim-whitespace` | `boolean` | no | `true` | This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. | +| `--pdf-use-cropbox` | `boolean` | no | `true` | 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… | +| `--turbo` | `boolean` | no | `true` | If you set this to false, the robot will not emit files as they become available. | **Input & output flags** @@ -584,7 +576,6 @@ npx transloadit document thumbs --input [options] **Examples** ```bash -# Run the command transloadit document thumbs --input input.pdf --out output/ ``` @@ -602,7 +593,7 @@ npx transloadit audio waveform --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/audio/waveform` @@ -611,32 +602,32 @@ npx transloadit audio waveform --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--ffmpeg` | `json` | no | `value` | 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` | `string` | no | `image` | 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` | `number` | no | `1` | The width of the resulting image if the format `"image"` was selected. | -| `--height` | `number` | no | `1` | The height of the resulting image if the format `"image"` was selected. | -| `--antialiasing` | `auto` | no | `0` | 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. | -| `--background-color` | `string` | no | `value` | The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format `"image"` was selected. | -| `--center-color` | `string` | no | `value` | The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | -| `--outer-color` | `string` | no | `value` | The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | -| `--style` | `string` | no | `v0` | Waveform style version. - `"v0"`: Legacy waveform generation (default). - `"v1"`: Advanced waveform generation with additional parameters. For backwards compatibility, numeric values `0`, `1`, `2` are also accepted and mapped to `"v0"` (0) and `"v1"` (1/2). | -| `--split-channels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, outputs multi-channel waveform data or image files, one per channel. | -| `--zoom` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in samples per pixel. This parameter cannot be used together with `pixels_per_second`. | -| `--pixels-per-second` | `number` | no | `1` | Available when style is `"v1"`. Zoom level in pixels per second. This parameter cannot be used together with `zoom`. | -| `--bits` | `number` | no | `8` | Available when style is `"v1"`. Bit depth for waveform data. Can be 8 or 16. | -| `--start` | `number` | no | `1` | Available when style is `"v1"`. Start time in seconds. | -| `--end` | `number` | no | `1` | Available when style is `"v1"`. End time in seconds (0 means end of audio). | -| `--colors` | `string` | no | `audition` | Available when style is `"v1"`. Color scheme to use. Can be "audition" or "audacity". | -| `--border-color` | `string` | no | `value` | Available when style is `"v1"`. Border color in "rrggbbaa" format. | -| `--waveform-style` | `string` | no | `normal` | Available when style is `"v1"`. Waveform style. Can be "normal" or "bars". | -| `--bar-width` | `number` | no | `1` | Available when style is `"v1"`. Width of bars in pixels when waveform_style is "bars". | -| `--bar-gap` | `number` | no | `1` | Available when style is `"v1"`. Gap between bars in pixels when waveform_style is "bars". | -| `--bar-style` | `string` | no | `square` | Available when style is `"v1"`. Bar style when waveform_style is "bars". | -| `--axis-label-color` | `string` | no | `value` | Available when style is `"v1"`. Color for axis labels in "rrggbbaa" format. | -| `--no-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image without axis labels. | -| `--with-axis-labels` | `boolean` | no | `true` | Available when style is `"v1"`. If set to `true`, renders waveform image with axis labels. | -| `--amplitude-scale` | `number` | no | `1` | Available when style is `"v1"`. Amplitude scale factor. | -| `--compression` | `number` | no | `1` | Available when style is `"v1"`. PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | +| `--ffmpeg` | `json` | no | `value` | 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… | +| `--format` | `string` | no | `image` | 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` | `number` | no | `1` | The width of the resulting image if the format "image" was selected. | +| `--height` | `number` | no | `1` | The height of the resulting image if the format "image" was selected. | +| `--antialiasing` | `auto` | no | `0` | 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. | +| `--background-color` | `string` | no | `value` | The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format "image" was selected. | +| `--center-color` | `string` | no | `value` | The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--outer-color` | `string` | no | `value` | The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--style` | `string` | no | `v0` | Waveform style version. - "v0": Legacy waveform generation (default). - "v1": Advanced waveform generation with additional parameters. For backwards compatibility, numeric values… | +| `--split-channels` | `boolean` | no | `true` | Available when style is "v1". If set to true, outputs multi-channel waveform data or image files, one per channel. | +| `--zoom` | `number` | no | `1` | Available when style is "v1". Zoom level in samples per pixel. This parameter cannot be used together with pixels_per_second. | +| `--pixels-per-second` | `number` | no | `1` | Available when style is "v1". Zoom level in pixels per second. This parameter cannot be used together with zoom. | +| `--bits` | `number` | no | `8` | Available when style is "v1". Bit depth for waveform data. Can be 8 or 16. | +| `--start` | `number` | no | `1` | Available when style is "v1". Start time in seconds. | +| `--end` | `number` | no | `1` | Available when style is "v1". End time in seconds (0 means end of audio). | +| `--colors` | `string` | no | `audition` | Available when style is "v1". Color scheme to use. Can be "audition" or "audacity". | +| `--border-color` | `string` | no | `value` | Available when style is "v1". Border color in "rrggbbaa" format. | +| `--waveform-style` | `string` | no | `normal` | Available when style is "v1". Waveform style. Can be "normal" or "bars". | +| `--bar-width` | `number` | no | `1` | Available when style is "v1". Width of bars in pixels when waveform_style is "bars". | +| `--bar-gap` | `number` | no | `1` | Available when style is "v1". Gap between bars in pixels when waveform_style is "bars". | +| `--bar-style` | `string` | no | `square` | Available when style is "v1". Bar style when waveform_style is "bars". | +| `--axis-label-color` | `string` | no | `value` | Available when style is "v1". Color for axis labels in "rrggbbaa" format. | +| `--no-axis-labels` | `boolean` | no | `true` | Available when style is "v1". If set to true, renders waveform image without axis labels. | +| `--with-axis-labels` | `boolean` | no | `true` | Available when style is "v1". If set to true, renders waveform image with axis labels. | +| `--amplitude-scale` | `number` | no | `1` | Available when style is "v1". Amplitude scale factor. | +| `--compression` | `number` | no | `1` | Available when style is "v1". PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | **Input & output flags** @@ -661,7 +652,6 @@ npx transloadit audio waveform --input [options] **Examples** ```bash -# Run the command transloadit audio waveform --input input.mp3 --out output.png ``` @@ -679,7 +669,7 @@ npx transloadit text speak --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/text/speak` @@ -688,11 +678,11 @@ npx transloadit text speak --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--prompt` | `string` | no | `"A red bicycle in a studio"` | Which text to speak. You can also set this to `null` and supply an input text file. | -| `--provider` | `string` | yes | `aws` | Which AI provider to leverage. Transloadit 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. | -| `--target-language` | `string` | no | `en-US` | The written language of the document. This will also be the language of the spoken text. The 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` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | -| `--ssml` | `boolean` | no | `true` | 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. Please 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). | +| `--prompt` | `string` | no | `"A red bicycle in a studio"` | Which text to speak. You can also set this to null and supply an input text file. | +| `--provider` | `string` | yes | `aws` | Which AI provider to leverage. Transloadit outsources this task and abstracts the interface so you can expect the same data structures, but different latencies and information… | +| `--target-language` | `string` | no | `en-US` | The written language of the document. This will also be the language of the spoken text. The language should be specified in the BCP-47 format, such as "en-GB", "de-DE" or… | +| `--voice` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | +| `--ssml` | `boolean` | no | `true` | Supply Speech Synthesis Markup Language instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations. | **Input & output flags** @@ -717,7 +707,6 @@ npx transloadit text speak --input [options] **Examples** ```bash -# Run the command transloadit text speak --input input.pdf --provider aws --out output.mp3 ``` @@ -735,7 +724,7 @@ npx transloadit video thumbs --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/video/thumbs` @@ -744,16 +733,16 @@ npx transloadit video thumbs --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--ffmpeg` | `json` | no | `value` | 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` | `number` | no | `1` | 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. The 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. To extract thumbnails for specific timestamps, use the `offsets` parameter. | -| `--offsets` | `auto` | no | `value` | 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%" ]`. This option cannot be used with the `count` parameter, and takes precedence if both are specified. Out-of-range offsets are silently ignored. | -| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | The width of the thumbnail, in pixels. Defaults to the original width of the video. | -| `--height` | `number` | no | `1` | The height of the thumbnail, in pixels. Defaults to the original height of the video. | -| `--resize-strategy` | `string` | no | `crop` | One of the [available resize strategies](/docs/topics/resize-strategies/). | -| `--background` | `string` | no | `value` | 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` | `number` | no | `0` | 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. | -| `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | +| `--ffmpeg` | `json` | no | `value` | 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… | +| `--count` | `number` | no | `1` | 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… | +| `--offsets` | `auto` | no | `value` | An array of offsets representing seconds of the file duration, such as [ 2, 45, 120 ]. | +| `--format` | `string` | no | `jpg` | 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… | +| `--width` | `number` | no | `1` | The width of the thumbnail, in pixels. Defaults to the original width of the video. | +| `--height` | `number` | no | `1` | The height of the thumbnail, in pixels. Defaults to the original height of the video. | +| `--resize-strategy` | `string` | no | `crop` | One of the available resize strategies. | +| `--background` | `string` | no | `value` | 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` | `number` | no | `0` | Forces the video to be rotated by the specified degree integer. | +| `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | **Input & output flags** @@ -778,7 +767,6 @@ npx transloadit video thumbs --input [options] **Examples** ```bash -# Run the command transloadit video thumbs --input input.mp4 --out output/ ``` @@ -796,7 +784,7 @@ npx transloadit video encode-hls --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `builtin/encode-hls-video@latest` @@ -824,7 +812,6 @@ npx transloadit video encode-hls --input [options] **Examples** ```bash -# Run the command transloadit video encode-hls --input input.mp4 --out output/ ``` @@ -842,7 +829,7 @@ npx transloadit image describe --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--watch` - Backend: semantic alias `image-describe` @@ -899,7 +886,7 @@ npx transloadit markdown pdf --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-pdf` @@ -953,7 +940,7 @@ npx transloadit markdown docx --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-docx` @@ -1007,7 +994,7 @@ npx transloadit file compress --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: file - Execution: single assembly - Backend: `/file/compress` @@ -1016,12 +1003,12 @@ npx transloadit file compress --input [options] | Flag | Type | Required | Example | Description | | --- | --- | --- | --- | --- | -| `--format` | `string` | no | `zip` | The format of the archive to be created. Supported values are `"tar"` and `"zip"`. Note that `"tar"` without setting `gzip` to `true` results in an archive that's not compressed in any way. | -| `--gzip` | `boolean` | no | `true` | Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the `"tar"` format. | -| `--password` | `string` | no | `value` | 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. This parameter has no effect if the format parameter is anything other than `"zip"`. | -| `--compression-level` | `number` | no | `1` | 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. If 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. | -| `--file-layout` | `string` | no | `advanced` | 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. Files with same names are numbered in the `"simple"` file layout to avoid naming collisions. | -| `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | +| `--format` | `string` | no | `zip` | The format of the archive to be created. Supported values are "tar" and "zip". Note that "tar" without setting gzip to true results in an archive that's not compressed in any way. | +| `--gzip` | `boolean` | no | `true` | Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the "tar" format. | +| `--password` | `string` | no | `value` | This allows you to encrypt all archive contents with a password and thereby protect it against unauthorized use. | +| `--compression-level` | `number` | no | `1` | 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… | +| `--file-layout` | `string` | no | `advanced` | 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… | +| `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | **Input & output flags** @@ -1043,7 +1030,6 @@ npx transloadit file compress --input [options] **Examples** ```bash -# Run the command transloadit file compress --input input.file --out output.file ``` @@ -1061,7 +1047,7 @@ npx transloadit file decompress --input [options] **Quick facts** -- Input: files, directories, URLs, base64 +- Input: file, dir, URL, base64 - Output: directory - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/decompress` @@ -1089,6 +1075,5 @@ npx transloadit file decompress --input [options] **Examples** ```bash -# Run the command transloadit file decompress --input input.file --out output/ ``` diff --git a/packages/node/src/cli/generateIntentDocs.ts b/packages/node/src/cli/generateIntentDocs.ts index bbe9e3b1..fa49aef7 100644 --- a/packages/node/src/cli/generateIntentDocs.ts +++ b/packages/node/src/cli/generateIntentDocs.ts @@ -15,6 +15,8 @@ interface DocOptionRow { type: string } +const MAX_OPTION_DESCRIPTION_LENGTH = 180 + function inlineCode(value: string): string { return `\`${value.replaceAll('`', '\\`')}\`` } @@ -32,12 +34,71 @@ function renderTable(headers: string[], rows: string[][]): string { ].join('\n') } +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function stripMarkdownLinks(value: string): string { + return value.replace(/!?\[([^\]]+)\]\([^)]+\)/g, '$1') +} + +function stripHtml(value: string): string { + return value.replace(/<[^>]+>/g, ' ') +} + +function stripCodeBlocks(value: string): string { + return value.replace(/```[\s\S]*?```/g, ' ') +} + +function stripTemplateSyntax(value: string): string { + return value.replace(/\{\{[\s\S]*?\}\}/g, ' ') +} + +function stripInlineCode(value: string): string { + return value.replaceAll('`', '') +} + +function truncateAtSentenceBoundary(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value + } + + const sentenceMatch = value.match(/^(.{1,180}?[.!?])(?:\s|$)/) + if (sentenceMatch?.[1] != null && sentenceMatch[1].length >= 60) { + return sentenceMatch[1] + } + + const truncated = value.slice(0, maxLength).trimEnd() + const lastSpace = truncated.lastIndexOf(' ') + if (lastSpace > 40) { + return `${truncated.slice(0, lastSpace)}…` + } + + return `${truncated}…` +} + +function summarizeDescription(value: string | undefined): string { + if (value == null || value.trim().length === 0) { + return '—' + } + + const sanitized = collapseWhitespace( + stripInlineCode(stripTemplateSyntax(stripCodeBlocks(stripHtml(stripMarkdownLinks(value))))), + ) + + if (sanitized.length === 0) { + return '—' + } + + return truncateAtSentenceBoundary(sanitized, MAX_OPTION_DESCRIPTION_LENGTH) +} + function getInputSummary(definition: ResolvedIntentCommandDefinition): string { if (definition.runnerKind === 'no-input') { return 'none' } - return 'files, directories, URLs, base64' + return 'file, dir, URL, base64' } function getOutputSummary(definition: ResolvedIntentCommandDefinition): string { @@ -110,7 +171,7 @@ function getCommandOptionRows(definition: ResolvedIntentCommandDefinition): DocO type: formatOptionType(field.kind), required: field.required ? 'yes' : 'no', example: getExampleValue(field), - description: field.description ?? '—', + description: summarizeDescription(field.description), })) } @@ -255,7 +316,9 @@ function renderExamples(examples: Array<[string, string]>): string { const lines: string[] = ['```bash'] for (const [label, command] of examples) { - lines.push(`# ${label}`) + if (examples.length > 1 || label !== 'Run the command') { + lines.push(`# ${label}`) + } lines.push(command) } From 1f35b2af3aa2956e10e6f21d59682bdcdf3d7114 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 09:19:01 +0200 Subject: [PATCH 58/69] refactor(node): share cli option docs metadata --- packages/node/README.md | 3 - .../node/src/cli/fileProcessingOptions.ts | 92 +++++++++++++++++ packages/node/src/cli/generateIntentDocs.ts | 99 +++++-------------- packages/node/src/cli/intentRuntime.ts | 22 +++++ 4 files changed, 138 insertions(+), 78 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 32a0246b..9575d57e 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -1943,6 +1943,3 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo See [CONTRIBUTING](./CONTRIBUTING.md). - - - diff --git a/packages/node/src/cli/fileProcessingOptions.ts b/packages/node/src/cli/fileProcessingOptions.ts index 6ccc4de0..5fb1b04f 100644 --- a/packages/node/src/cli/fileProcessingOptions.ts +++ b/packages/node/src/cli/fileProcessingOptions.ts @@ -1,6 +1,14 @@ import { Option } from 'clipanion' import * as t from 'typanion' +export interface SharedCliOptionDocumentation { + description: string + example: string + flags: string + required: string + type: string +} + export interface SharedFileProcessingValidationInput { explicitInputCount: number singleAssembly: boolean @@ -8,18 +16,54 @@ export interface SharedFileProcessingValidationInput { watchRequiresInputsMessage: string } +export function getInputPathsOptionDocumentation( + description = 'Provide an input path, directory, URL, or - for stdin', +): SharedCliOptionDocumentation { + return { + flags: '--input, -i', + type: 'path | dir | url | -', + required: 'varies', + example: 'input.file', + description, + } +} + export function inputPathsOption(description = 'Provide an input file or a directory'): string[] { return Option.Array('--input,-i', { description, }) as unknown as string[] } +export function getRecursiveOptionDocumentation( + description = 'Enumerate input directories recursively', +): SharedCliOptionDocumentation { + return { + flags: '--recursive, -r', + type: 'boolean', + required: 'no', + example: 'false', + description, + } +} + export function recursiveOption(description = 'Enumerate input directories recursively'): boolean { return Option.Boolean('--recursive,-r', false, { description, }) as unknown as boolean } +export function getDeleteAfterProcessingOptionDocumentation( + description = 'Delete input files after they are processed', +): SharedCliOptionDocumentation { + return { + flags: '--delete-after-processing, -d', + type: 'boolean', + required: 'no', + example: 'false', + description, + } +} + export function deleteAfterProcessingOption( description = 'Delete input files after they are processed', ): boolean { @@ -28,6 +72,18 @@ export function deleteAfterProcessingOption( }) as unknown as boolean } +export function getReprocessStaleOptionDocumentation( + description = 'Process inputs even if output is newer', +): SharedCliOptionDocumentation { + return { + flags: '--reprocess-stale', + type: 'boolean', + required: 'no', + example: 'false', + description, + } +} + export function reprocessStaleOption( description = 'Process inputs even if output is newer', ): boolean { @@ -36,12 +92,36 @@ export function reprocessStaleOption( }) as unknown as boolean } +export function getWatchOptionDocumentation( + description = 'Watch inputs for changes', +): SharedCliOptionDocumentation { + return { + flags: '--watch, -w', + type: 'boolean', + required: 'no', + example: 'false', + description, + } +} + export function watchOption(description = 'Watch inputs for changes'): boolean { return Option.Boolean('--watch,-w', false, { description, }) as unknown as boolean } +export function getSingleAssemblyOptionDocumentation( + description = 'Pass all input files to a single assembly instead of one assembly per file', +): SharedCliOptionDocumentation { + return { + flags: '--single-assembly', + type: 'boolean', + required: 'no', + example: 'false', + description, + } +} + export function singleAssemblyOption( description = 'Pass all input files to a single assembly instead of one assembly per file', ): boolean { @@ -50,6 +130,18 @@ export function singleAssemblyOption( }) as unknown as boolean } +export function getConcurrencyOptionDocumentation( + description = 'Maximum number of concurrent assemblies (default: 5)', +): SharedCliOptionDocumentation { + return { + flags: '--concurrency, -c', + type: 'number', + required: 'no', + example: '5', + description, + } +} + export function concurrencyOption( description = 'Maximum number of concurrent assemblies (default: 5)', ): number | undefined { diff --git a/packages/node/src/cli/generateIntentDocs.ts b/packages/node/src/cli/generateIntentDocs.ts index fa49aef7..5296aa02 100644 --- a/packages/node/src/cli/generateIntentDocs.ts +++ b/packages/node/src/cli/generateIntentDocs.ts @@ -1,11 +1,23 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' - +import { + getConcurrencyOptionDocumentation, + getDeleteAfterProcessingOptionDocumentation, + getInputPathsOptionDocumentation, + getRecursiveOptionDocumentation, + getReprocessStaleOptionDocumentation, + getSingleAssemblyOptionDocumentation, + getWatchOptionDocumentation, +} from './fileProcessingOptions.ts' import type { IntentDefinition } from './intentCommandSpecs.ts' import type { ResolvedIntentCommandDefinition } from './intentCommands.ts' import { resolveIntentCommandDefinitions } from './intentCommands.ts' import type { IntentOptionDefinition } from './intentRuntime.ts' -import { getIntentOptionDefinitions } from './intentRuntime.ts' +import { + getInputBase64OptionDocumentation, + getIntentOptionDefinitions, + getPrintUrlsOptionDocumentation, +} from './intentRuntime.ts' interface DocOptionRow { description: string @@ -157,7 +169,7 @@ function formatOptionType(kind: IntentOptionDefinition['kind']): string { } function getExampleValue(field: IntentOptionDefinition): string { - const candidate = (field as IntentOptionDefinition & { exampleValue?: unknown }).exampleValue + const candidate = field.exampleValue if (typeof candidate === 'string' && candidate.length > 0) { return candidate } @@ -187,31 +199,13 @@ function getInputOutputRows(definition: ResolvedIntentCommandDefinition): DocOpt example: definition.intentDefinition.outputMode === 'directory' ? 'output/' : 'output.file', description: definition.intentDefinition.outputDescription, }, - { - flags: '--print-urls', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Print temporary result URLs after completion', - }, + getPrintUrlsOptionDocumentation(), ] } return [ - { - flags: '--input, -i', - type: 'path | dir | url | -', - required: 'varies', - example: 'input.file', - description: 'Provide an input path, directory, URL, or - for stdin', - }, - { - flags: '--input-base64', - type: 'base64 | data URL', - required: 'no', - example: 'data:text/plain;base64,SGVsbG8=', - description: 'Provide base64-encoded input content directly', - }, + getInputPathsOptionDocumentation(), + getInputBase64OptionDocumentation(), { flags: '--out, -o', type: outputType, @@ -219,13 +213,7 @@ function getInputOutputRows(definition: ResolvedIntentCommandDefinition): DocOpt example: definition.intentDefinition.outputMode === 'directory' ? 'output/' : 'output.file', description: definition.intentDefinition.outputDescription, }, - { - flags: '--print-urls', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Print temporary result URLs after completion', - }, + getPrintUrlsOptionDocumentation(), ] } @@ -235,56 +223,17 @@ function getProcessingRows(definition: ResolvedIntentCommandDefinition): DocOpti } const rows: DocOptionRow[] = [ - { - flags: '--recursive, -r', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Enumerate input directories recursively', - }, - { - flags: '--delete-after-processing, -d', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Delete input files after they are processed', - }, - { - flags: '--reprocess-stale', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Process inputs even if output is newer', - }, + getRecursiveOptionDocumentation(), + getDeleteAfterProcessingOptionDocumentation(), + getReprocessStaleOptionDocumentation(), ] if (definition.runnerKind === 'standard' || definition.runnerKind === 'watchable') { - rows.push( - { - flags: '--watch, -w', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Watch inputs for changes', - }, - { - flags: '--concurrency, -c', - type: 'number', - required: 'no', - example: '5', - description: 'Maximum number of concurrent assemblies (default: 5)', - }, - ) + rows.push(getWatchOptionDocumentation(), getConcurrencyOptionDocumentation()) } if (definition.runnerKind === 'standard') { - rows.push({ - flags: '--single-assembly', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Pass all input files to a single assembly instead of one assembly per file', - }) + rows.push(getSingleAssemblyOptionDocumentation()) } return rows diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 2930a3e1..7aea0ea1 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -7,6 +7,7 @@ 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 type { SharedCliOptionDocumentation } from './fileProcessingOptions.ts' import { concurrencyOption, countProvidedInputs, @@ -83,11 +84,32 @@ export interface IntentCommandDefinition { export interface IntentOptionDefinition extends IntentFieldSpec { description?: string + exampleValue?: unknown optionFlags: string propertyName: string required?: boolean } +export function getInputBase64OptionDocumentation(): SharedCliOptionDocumentation { + return { + flags: '--input-base64', + type: 'base64 | data URL', + required: 'no', + example: 'data:text/plain;base64,SGVsbG8=', + description: 'Provide base64-encoded input content directly', + } +} + +export function getPrintUrlsOptionDocumentation(): SharedCliOptionDocumentation { + return { + flags: '--print-urls', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Print temporary result URLs after completion', + } +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value) From d2bfcf9408524e0a918def7a6364861139233560 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 09:45:59 +0200 Subject: [PATCH 59/69] refactor(node): centralize cli option definitions --- packages/node/src/cli/commands/assemblies.ts | 59 +++--- .../node/src/cli/fileProcessingOptions.ts | 190 +++++++++++++----- packages/node/src/cli/generateIntentDocs.ts | 7 +- packages/node/src/cli/intentRuntime.ts | 37 ++-- .../node/src/cli/semanticIntents/index.ts | 24 +-- .../src/cli/semanticIntents/markdownPdf.ts | 26 +-- 6 files changed, 206 insertions(+), 137 deletions(-) diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index b005a3cd..30a8391c 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -27,6 +27,7 @@ import { concurrencyOption, deleteAfterProcessingOption, inputPathsOption, + printUrlsOption, recursiveOption, reprocessStaleOption, singleAssemblyOption, @@ -77,6 +78,30 @@ export interface AssemblyLintOptions { json?: boolean } +function parseTemplateFieldAssignments( + output: IOutputCtl, + fields: string[] | undefined, +): Record | undefined { + if (fields == null || fields.length === 0) { + return undefined + } + + const fieldsMap: Record = {} + for (const field of fields) { + const eqIndex = field.indexOf('=') + if (eqIndex === -1) { + output.error(`invalid argument for --field: '${field}'`) + return undefined + } + + const key = field.slice(0, eqIndex) + const value = field.slice(eqIndex + 1) + fieldsMap[key] = value + } + + return fieldsMap +} + const AssemblySchema = z.object({ id: z.string(), }) @@ -1632,9 +1657,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { concurrency = concurrencyOption() - printUrls = Option.Boolean('--print-urls', { - description: 'Print temporary result URLs after completion', - }) + printUrls = printUrlsOption() protected async run(): Promise { if (!this.steps && !this.template) { @@ -1654,16 +1677,9 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { inputList.push('-') } - const fieldsMap: Record = {} - for (const field of this.fields ?? []) { - const eqIndex = field.indexOf('=') - if (eqIndex === -1) { - this.output.error(`invalid argument for --field: '${field}'`) - return 1 - } - const key = field.slice(0, eqIndex) - const value = field.slice(eqIndex + 1) - fieldsMap[key] = value + const fieldsMap = parseTemplateFieldAssignments(this.output, this.fields) + if (this.fields != null && fieldsMap == null) { + return 1 } const sharedValidationError = validateSharedFileProcessingOptions({ @@ -1680,7 +1696,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand { const { hasFailures, resultUrls } = await create(this.output, this.client, { steps: this.steps, template: this.template, - fields: fieldsMap, + fields: fieldsMap ?? {}, watch: this.watch, recursive: this.recursive, inputs: inputList, @@ -1841,20 +1857,13 @@ export class AssembliesReplayCommand extends AuthenticatedCommand { assemblyIds = Option.Rest({ required: 1 }) protected async run(): Promise { - const fieldsMap: Record = {} - for (const field of this.fields ?? []) { - const eqIndex = field.indexOf('=') - if (eqIndex === -1) { - this.output.error(`invalid argument for --field: '${field}'`) - return 1 - } - const key = field.slice(0, eqIndex) - const value = field.slice(eqIndex + 1) - fieldsMap[key] = value + const fieldsMap = parseTemplateFieldAssignments(this.output, this.fields) + if (this.fields != null && fieldsMap == null) { + return 1 } await replay(this.output, this.client, { - fields: fieldsMap, + fields: fieldsMap ?? {}, reparse: this.reparseTemplate, steps: this.steps, notify_url: this.notifyUrl, diff --git a/packages/node/src/cli/fileProcessingOptions.ts b/packages/node/src/cli/fileProcessingOptions.ts index 5fb1b04f..d97d7762 100644 --- a/packages/node/src/cli/fileProcessingOptions.ts +++ b/packages/node/src/cli/fileProcessingOptions.ts @@ -9,6 +9,16 @@ export interface SharedCliOptionDocumentation { type: string } +interface SharedCliBooleanOptionDefinition { + docs: SharedCliOptionDocumentation + optionFlags: string +} + +interface SharedCliStringOptionDefinition { + docs: SharedCliOptionDocumentation + optionFlags: string +} + export interface SharedFileProcessingValidationInput { explicitInputCount: number singleAssembly: boolean @@ -16,141 +26,225 @@ export interface SharedFileProcessingValidationInput { watchRequiresInputsMessage: string } -export function getInputPathsOptionDocumentation( - description = 'Provide an input path, directory, URL, or - for stdin', -): SharedCliOptionDocumentation { - return { +const inputPathsOptionDefinition = { + docs: { flags: '--input, -i', type: 'path | dir | url | -', required: 'varies', example: 'input.file', + description: 'Provide an input path, directory, URL, or - for stdin', + }, + optionFlags: '--input,-i', +} as const satisfies SharedCliStringOptionDefinition + +const recursiveOptionDefinition = { + docs: { + flags: '--recursive, -r', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Enumerate input directories recursively', + }, + optionFlags: '--recursive,-r', +} as const satisfies SharedCliBooleanOptionDefinition + +const deleteAfterProcessingOptionDefinition = { + docs: { + flags: '--delete-after-processing, -d', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Delete input files after they are processed', + }, + optionFlags: '--delete-after-processing,-d', +} as const satisfies SharedCliBooleanOptionDefinition + +const reprocessStaleOptionDefinition = { + docs: { + flags: '--reprocess-stale', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Process inputs even if output is newer', + }, + optionFlags: '--reprocess-stale', +} as const satisfies SharedCliBooleanOptionDefinition + +const watchOptionDefinition = { + docs: { + flags: '--watch, -w', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Watch inputs for changes', + }, + optionFlags: '--watch,-w', +} as const satisfies SharedCliBooleanOptionDefinition + +const singleAssemblyOptionDefinition = { + docs: { + flags: '--single-assembly', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Pass all input files to a single assembly instead of one assembly per file', + }, + optionFlags: '--single-assembly', +} as const satisfies SharedCliBooleanOptionDefinition + +const concurrencyOptionDefinition = { + docs: { + flags: '--concurrency, -c', + type: 'number', + required: 'no', + example: '5', + description: 'Maximum number of concurrent assemblies (default: 5)', + }, + optionFlags: '--concurrency,-c', +} as const satisfies SharedCliStringOptionDefinition + +const printUrlsOptionDefinition = { + docs: { + flags: '--print-urls', + type: 'boolean', + required: 'no', + example: 'false', + description: 'Print temporary result URLs after completion', + }, + optionFlags: '--print-urls', +} as const satisfies SharedCliBooleanOptionDefinition + +export function getInputPathsOptionDocumentation( + description = inputPathsOptionDefinition.docs.description, +): SharedCliOptionDocumentation { + return { + ...inputPathsOptionDefinition.docs, description, } } -export function inputPathsOption(description = 'Provide an input file or a directory'): string[] { - return Option.Array('--input,-i', { +export function inputPathsOption( + description = inputPathsOptionDefinition.docs.description, +): string[] { + return Option.Array(inputPathsOptionDefinition.optionFlags, { description, }) as unknown as string[] } export function getRecursiveOptionDocumentation( - description = 'Enumerate input directories recursively', + description = recursiveOptionDefinition.docs.description, ): SharedCliOptionDocumentation { return { - flags: '--recursive, -r', - type: 'boolean', - required: 'no', - example: 'false', + ...recursiveOptionDefinition.docs, description, } } -export function recursiveOption(description = 'Enumerate input directories recursively'): boolean { - return Option.Boolean('--recursive,-r', false, { +export function recursiveOption(description = recursiveOptionDefinition.docs.description): boolean { + return Option.Boolean(recursiveOptionDefinition.optionFlags, false, { description, }) as unknown as boolean } export function getDeleteAfterProcessingOptionDocumentation( - description = 'Delete input files after they are processed', + description = deleteAfterProcessingOptionDefinition.docs.description, ): SharedCliOptionDocumentation { return { - flags: '--delete-after-processing, -d', - type: 'boolean', - required: 'no', - example: 'false', + ...deleteAfterProcessingOptionDefinition.docs, description, } } export function deleteAfterProcessingOption( - description = 'Delete input files after they are processed', + description = deleteAfterProcessingOptionDefinition.docs.description, ): boolean { - return Option.Boolean('--delete-after-processing,-d', false, { + return Option.Boolean(deleteAfterProcessingOptionDefinition.optionFlags, false, { description, }) as unknown as boolean } export function getReprocessStaleOptionDocumentation( - description = 'Process inputs even if output is newer', + description = reprocessStaleOptionDefinition.docs.description, ): SharedCliOptionDocumentation { return { - flags: '--reprocess-stale', - type: 'boolean', - required: 'no', - example: 'false', + ...reprocessStaleOptionDefinition.docs, description, } } export function reprocessStaleOption( - description = 'Process inputs even if output is newer', + description = reprocessStaleOptionDefinition.docs.description, ): boolean { - return Option.Boolean('--reprocess-stale', false, { + return Option.Boolean(reprocessStaleOptionDefinition.optionFlags, false, { description, }) as unknown as boolean } export function getWatchOptionDocumentation( - description = 'Watch inputs for changes', + description = watchOptionDefinition.docs.description, ): SharedCliOptionDocumentation { return { - flags: '--watch, -w', - type: 'boolean', - required: 'no', - example: 'false', + ...watchOptionDefinition.docs, description, } } -export function watchOption(description = 'Watch inputs for changes'): boolean { - return Option.Boolean('--watch,-w', false, { +export function watchOption(description = watchOptionDefinition.docs.description): boolean { + return Option.Boolean(watchOptionDefinition.optionFlags, false, { description, }) as unknown as boolean } export function getSingleAssemblyOptionDocumentation( - description = 'Pass all input files to a single assembly instead of one assembly per file', + description = singleAssemblyOptionDefinition.docs.description, ): SharedCliOptionDocumentation { return { - flags: '--single-assembly', - type: 'boolean', - required: 'no', - example: 'false', + ...singleAssemblyOptionDefinition.docs, description, } } export function singleAssemblyOption( - description = 'Pass all input files to a single assembly instead of one assembly per file', + description = singleAssemblyOptionDefinition.docs.description, ): boolean { - return Option.Boolean('--single-assembly', false, { + return Option.Boolean(singleAssemblyOptionDefinition.optionFlags, false, { description, }) as unknown as boolean } export function getConcurrencyOptionDocumentation( - description = 'Maximum number of concurrent assemblies (default: 5)', + description = concurrencyOptionDefinition.docs.description, ): SharedCliOptionDocumentation { return { - flags: '--concurrency, -c', - type: 'number', - required: 'no', - example: '5', + ...concurrencyOptionDefinition.docs, description, } } export function concurrencyOption( - description = 'Maximum number of concurrent assemblies (default: 5)', + description = concurrencyOptionDefinition.docs.description, ): number | undefined { - return Option.String('--concurrency,-c', { + return Option.String(concurrencyOptionDefinition.optionFlags, { description, validator: t.applyCascade(t.isNumber(), [t.isAtLeast(1)]), }) as unknown as number | undefined } +export function getPrintUrlsOptionDocumentation( + description = printUrlsOptionDefinition.docs.description, +): SharedCliOptionDocumentation { + return { + ...printUrlsOptionDefinition.docs, + description, + } +} + +export function printUrlsOption(description = printUrlsOptionDefinition.docs.description): boolean { + return Option.Boolean(printUrlsOptionDefinition.optionFlags, { + description, + }) as unknown as boolean +} + export function countProvidedInputs({ inputBase64, inputs, diff --git a/packages/node/src/cli/generateIntentDocs.ts b/packages/node/src/cli/generateIntentDocs.ts index 5296aa02..b575622c 100644 --- a/packages/node/src/cli/generateIntentDocs.ts +++ b/packages/node/src/cli/generateIntentDocs.ts @@ -4,6 +4,7 @@ import { getConcurrencyOptionDocumentation, getDeleteAfterProcessingOptionDocumentation, getInputPathsOptionDocumentation, + getPrintUrlsOptionDocumentation, getRecursiveOptionDocumentation, getReprocessStaleOptionDocumentation, getSingleAssemblyOptionDocumentation, @@ -13,11 +14,7 @@ import type { IntentDefinition } from './intentCommandSpecs.ts' import type { ResolvedIntentCommandDefinition } from './intentCommands.ts' import { resolveIntentCommandDefinitions } from './intentCommands.ts' import type { IntentOptionDefinition } from './intentRuntime.ts' -import { - getInputBase64OptionDocumentation, - getIntentOptionDefinitions, - getPrintUrlsOptionDocumentation, -} from './intentRuntime.ts' +import { getInputBase64OptionDocumentation, getIntentOptionDefinitions } from './intentRuntime.ts' interface DocOptionRow { description: string diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 7aea0ea1..0b1cb0ea 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -13,6 +13,7 @@ import { countProvidedInputs, deleteAfterProcessingOption, inputPathsOption, + printUrlsOption, recursiveOption, reprocessStaleOption, singleAssemblyOption, @@ -90,24 +91,22 @@ export interface IntentOptionDefinition extends IntentFieldSpec { required?: boolean } +const inputBase64OptionDocumentation = { + flags: '--input-base64', + type: 'base64 | data URL', + required: 'no', + example: 'data:text/plain;base64,SGVsbG8=', + description: 'Provide base64-encoded input content directly', +} as const satisfies SharedCliOptionDocumentation + export function getInputBase64OptionDocumentation(): SharedCliOptionDocumentation { - return { - flags: '--input-base64', - type: 'base64 | data URL', - required: 'no', - example: 'data:text/plain;base64,SGVsbG8=', - description: 'Provide base64-encoded input content directly', - } + return inputBase64OptionDocumentation } -export function getPrintUrlsOptionDocumentation(): SharedCliOptionDocumentation { - return { - flags: '--print-urls', - type: 'boolean', - required: 'no', - example: 'false', - description: 'Print temporary result URLs after completion', - } +export function inputBase64Option(): string[] { + return Option.Array(inputBase64OptionDocumentation.flags, { + description: inputBase64OptionDocumentation.description, + }) as unknown as string[] } function isHttpUrl(value: string): boolean { @@ -388,9 +387,7 @@ abstract class GeneratedIntentCommandBase extends AuthenticatedCommand { description: this.getOutputDescription(), }) - printUrls = Option.Boolean('--print-urls', { - description: 'Print temporary result URLs after completion', - }) + printUrls = printUrlsOption() protected getIntentDefinition(): IntentFileCommandDefinition | IntentNoInputCommandDefinition { const commandClass = this.constructor as unknown as typeof GeneratedIntentCommandBase @@ -464,9 +461,7 @@ export function readIntentRawValues( export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase { inputs = inputPathsOption('Provide an input path, directory, URL, or - for stdin') - inputBase64 = Option.Array('--input-base64', { - description: 'Provide base64-encoded input content directly', - }) + inputBase64 = inputBase64Option() recursive = recursiveOption() diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts index 6d4bd855..92dc40e3 100644 --- a/packages/node/src/cli/semanticIntents/index.ts +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -10,14 +10,8 @@ import { imageDescribeExecutionDefinition, } from './imageDescribe.ts' import { - createMarkdownDocxStep, - createMarkdownPdfStep, - markdownDocxCommandPresentation, - markdownDocxExecutionDefinition, - markdownDocxOutputDescription, - markdownPdfCommandPresentation, - markdownPdfExecutionDefinition, - markdownPdfOutputDescription, + markdownDocxSemanticIntentDescriptor, + markdownPdfSemanticIntentDescriptor, } from './markdownPdf.ts' export interface SemanticIntentDescriptor { @@ -47,20 +41,10 @@ export const semanticIntentDescriptors: Record runnerKind: 'watchable', }, 'markdown-pdf': { - createStep: createMarkdownPdfStep, - execution: markdownPdfExecutionDefinition, - inputPolicy: { kind: 'required' }, - outputDescription: markdownPdfOutputDescription, - presentation: markdownPdfCommandPresentation, - runnerKind: 'watchable', + ...markdownPdfSemanticIntentDescriptor, }, 'markdown-docx': { - createStep: createMarkdownDocxStep, - execution: markdownDocxExecutionDefinition, - inputPolicy: { kind: 'required' }, - outputDescription: markdownDocxOutputDescription, - presentation: markdownDocxCommandPresentation, - runnerKind: 'watchable', + ...markdownDocxSemanticIntentDescriptor, }, } diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index c82b639f..98e1364d 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -1,6 +1,8 @@ +import type { IntentInputPolicy } from '../intentInputPolicy.ts' import type { IntentDynamicStepExecutionDefinition, IntentOptionDefinition, + IntentRunnerKind, } from '../intentRuntime.ts' const defaultMarkdownFormat = 'gfm' @@ -56,12 +58,14 @@ const markdownOptionDefinitions = [ interface MarkdownConvertSemanticIntentDefinition { createStep: (rawValues: Record) => Record execution: IntentDynamicStepExecutionDefinition + inputPolicy: IntentInputPolicy outputDescription: string presentation: { description: string details: string examples: Array<[string, string]> } + runnerKind: IntentRunnerKind } function createMarkdownConvertSemanticIntent({ @@ -98,6 +102,7 @@ function createMarkdownConvertSemanticIntent({ resultStepName: 'convert', fields: markdownOptionDefinitions, }, + inputPolicy: { kind: 'required' }, outputDescription: `Write the rendered ${formatLabel} to this path or directory`, presentation: { description, @@ -113,10 +118,11 @@ function createMarkdownConvertSemanticIntent({ ], ], }, + runnerKind: 'watchable', } } -export const markdownPdfSemanticIntent = createMarkdownConvertSemanticIntent({ +export const markdownPdfSemanticIntentDescriptor = createMarkdownConvertSemanticIntent({ description: 'Render Markdown files as PDFs', details: 'Runs `/document/convert` with `format: pdf`, letting the backend render Markdown and preserve features such as internal heading links in the generated PDF.', @@ -125,7 +131,7 @@ export const markdownPdfSemanticIntent = createMarkdownConvertSemanticIntent({ handler: 'markdown-pdf', }) -export const markdownDocxSemanticIntent = createMarkdownConvertSemanticIntent({ +export const markdownDocxSemanticIntentDescriptor = createMarkdownConvertSemanticIntent({ description: 'Render Markdown files as DOCX documents', details: 'Runs `/document/convert` with `format: docx`, letting the backend render Markdown and convert it into a Word document.', @@ -133,19 +139,3 @@ export const markdownDocxSemanticIntent = createMarkdownConvertSemanticIntent({ format: 'docx', handler: 'markdown-docx', }) - -export const markdownPdfExecutionDefinition = markdownPdfSemanticIntent.execution - -export const markdownDocxExecutionDefinition = markdownDocxSemanticIntent.execution - -export const markdownPdfCommandPresentation = markdownPdfSemanticIntent.presentation - -export const markdownDocxCommandPresentation = markdownDocxSemanticIntent.presentation - -export const createMarkdownPdfStep = markdownPdfSemanticIntent.createStep - -export const createMarkdownDocxStep = markdownDocxSemanticIntent.createStep - -export const markdownPdfOutputDescription = markdownPdfSemanticIntent.outputDescription - -export const markdownDocxOutputDescription = markdownDocxSemanticIntent.outputDescription From 3b3a8aa5ddf26c5cd86770fcf5f04fe64a195abd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 10:19:12 +0200 Subject: [PATCH 60/69] fix(node): normalize top-level cli errors --- packages/node/src/cli.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/node/src/cli.ts b/packages/node/src/cli.ts index bf62dd17..d7eedc1e 100644 --- a/packages/node/src/cli.ts +++ b/packages/node/src/cli.ts @@ -6,6 +6,7 @@ import process from 'node:process' import { fileURLToPath } from 'node:url' import 'dotenv/config' import { createCli } from './cli/commands/index.ts' +import { ensureError } from './cli/types.ts' const currentFile = realpathSync(fileURLToPath(import.meta.url)) @@ -36,7 +37,7 @@ export async function runCliWhenExecuted(): Promise { if (!shouldRunCli(process.argv[1])) return await main().catch((error) => { - console.error((error as Error).message) + console.error(ensureError(error).message) process.exitCode = 1 }) } From 7fdcda1c93afe915c2ef85fcaeeec312104edf4d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 14:56:31 +0200 Subject: [PATCH 61/69] refactor(node): share semantic intent parsing helpers --- packages/node/README.md | 1 - .../node/src/cli/fileProcessingOptions.ts | 113 ++++++++---------- packages/node/src/cli/generateIntentDocs.ts | 35 ++---- .../src/cli/semanticIntents/imageDescribe.ts | 45 ++----- .../src/cli/semanticIntents/markdownPdf.ts | 35 +++--- .../node/src/cli/semanticIntents/parsing.ts | 56 +++++++++ 6 files changed, 142 insertions(+), 143 deletions(-) create mode 100644 packages/node/src/cli/semanticIntents/parsing.ts diff --git a/packages/node/README.md b/packages/node/README.md index 9575d57e..e7949029 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -1942,4 +1942,3 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo ## Development See [CONTRIBUTING](./CONTRIBUTING.md). - diff --git a/packages/node/src/cli/fileProcessingOptions.ts b/packages/node/src/cli/fileProcessingOptions.ts index d97d7762..6029ed75 100644 --- a/packages/node/src/cli/fileProcessingOptions.ts +++ b/packages/node/src/cli/fileProcessingOptions.ts @@ -9,12 +9,7 @@ export interface SharedCliOptionDocumentation { type: string } -interface SharedCliBooleanOptionDefinition { - docs: SharedCliOptionDocumentation - optionFlags: string -} - -interface SharedCliStringOptionDefinition { +interface SharedCliOptionDefinition { docs: SharedCliOptionDocumentation optionFlags: string } @@ -35,7 +30,7 @@ const inputPathsOptionDefinition = { description: 'Provide an input path, directory, URL, or - for stdin', }, optionFlags: '--input,-i', -} as const satisfies SharedCliStringOptionDefinition +} as const satisfies SharedCliOptionDefinition const recursiveOptionDefinition = { docs: { @@ -46,7 +41,7 @@ const recursiveOptionDefinition = { description: 'Enumerate input directories recursively', }, optionFlags: '--recursive,-r', -} as const satisfies SharedCliBooleanOptionDefinition +} as const satisfies SharedCliOptionDefinition const deleteAfterProcessingOptionDefinition = { docs: { @@ -57,7 +52,7 @@ const deleteAfterProcessingOptionDefinition = { description: 'Delete input files after they are processed', }, optionFlags: '--delete-after-processing,-d', -} as const satisfies SharedCliBooleanOptionDefinition +} as const satisfies SharedCliOptionDefinition const reprocessStaleOptionDefinition = { docs: { @@ -68,7 +63,7 @@ const reprocessStaleOptionDefinition = { description: 'Process inputs even if output is newer', }, optionFlags: '--reprocess-stale', -} as const satisfies SharedCliBooleanOptionDefinition +} as const satisfies SharedCliOptionDefinition const watchOptionDefinition = { docs: { @@ -79,7 +74,7 @@ const watchOptionDefinition = { description: 'Watch inputs for changes', }, optionFlags: '--watch,-w', -} as const satisfies SharedCliBooleanOptionDefinition +} as const satisfies SharedCliOptionDefinition const singleAssemblyOptionDefinition = { docs: { @@ -90,7 +85,7 @@ const singleAssemblyOptionDefinition = { description: 'Pass all input files to a single assembly instead of one assembly per file', }, optionFlags: '--single-assembly', -} as const satisfies SharedCliBooleanOptionDefinition +} as const satisfies SharedCliOptionDefinition const concurrencyOptionDefinition = { docs: { @@ -101,7 +96,7 @@ const concurrencyOptionDefinition = { description: 'Maximum number of concurrent assemblies (default: 5)', }, optionFlags: '--concurrency,-c', -} as const satisfies SharedCliStringOptionDefinition +} as const satisfies SharedCliOptionDefinition const printUrlsOptionDefinition = { docs: { @@ -112,113 +107,108 @@ const printUrlsOptionDefinition = { description: 'Print temporary result URLs after completion', }, optionFlags: '--print-urls', -} as const satisfies SharedCliBooleanOptionDefinition +} as const satisfies SharedCliOptionDefinition -export function getInputPathsOptionDocumentation( - description = inputPathsOptionDefinition.docs.description, +function getSharedCliOptionDocumentation( + definition: SharedCliOptionDefinition, + description = definition.docs.description, ): SharedCliOptionDocumentation { return { - ...inputPathsOptionDefinition.docs, + ...definition.docs, description, } } -export function inputPathsOption( - description = inputPathsOptionDefinition.docs.description, +function arrayOption( + definition: SharedCliOptionDefinition, + description = definition.docs.description, ): string[] { - return Option.Array(inputPathsOptionDefinition.optionFlags, { + return Option.Array(definition.optionFlags, { description, }) as unknown as string[] } +function booleanOption( + definition: SharedCliOptionDefinition, + description = definition.docs.description, +): boolean { + return Option.Boolean(definition.optionFlags, false, { + description, + }) as unknown as boolean +} + +export function getInputPathsOptionDocumentation( + description = inputPathsOptionDefinition.docs.description, +): SharedCliOptionDocumentation { + return getSharedCliOptionDocumentation(inputPathsOptionDefinition, description) +} + +export function inputPathsOption( + description = inputPathsOptionDefinition.docs.description, +): string[] { + return arrayOption(inputPathsOptionDefinition, description) +} + export function getRecursiveOptionDocumentation( description = recursiveOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return { - ...recursiveOptionDefinition.docs, - description, - } + return getSharedCliOptionDocumentation(recursiveOptionDefinition, description) } export function recursiveOption(description = recursiveOptionDefinition.docs.description): boolean { - return Option.Boolean(recursiveOptionDefinition.optionFlags, false, { - description, - }) as unknown as boolean + return booleanOption(recursiveOptionDefinition, description) } export function getDeleteAfterProcessingOptionDocumentation( description = deleteAfterProcessingOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return { - ...deleteAfterProcessingOptionDefinition.docs, - description, - } + return getSharedCliOptionDocumentation(deleteAfterProcessingOptionDefinition, description) } export function deleteAfterProcessingOption( description = deleteAfterProcessingOptionDefinition.docs.description, ): boolean { - return Option.Boolean(deleteAfterProcessingOptionDefinition.optionFlags, false, { - description, - }) as unknown as boolean + return booleanOption(deleteAfterProcessingOptionDefinition, description) } export function getReprocessStaleOptionDocumentation( description = reprocessStaleOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return { - ...reprocessStaleOptionDefinition.docs, - description, - } + return getSharedCliOptionDocumentation(reprocessStaleOptionDefinition, description) } export function reprocessStaleOption( description = reprocessStaleOptionDefinition.docs.description, ): boolean { - return Option.Boolean(reprocessStaleOptionDefinition.optionFlags, false, { - description, - }) as unknown as boolean + return booleanOption(reprocessStaleOptionDefinition, description) } export function getWatchOptionDocumentation( description = watchOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return { - ...watchOptionDefinition.docs, - description, - } + return getSharedCliOptionDocumentation(watchOptionDefinition, description) } export function watchOption(description = watchOptionDefinition.docs.description): boolean { - return Option.Boolean(watchOptionDefinition.optionFlags, false, { - description, - }) as unknown as boolean + return booleanOption(watchOptionDefinition, description) } export function getSingleAssemblyOptionDocumentation( description = singleAssemblyOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return { - ...singleAssemblyOptionDefinition.docs, - description, - } + return getSharedCliOptionDocumentation(singleAssemblyOptionDefinition, description) } export function singleAssemblyOption( description = singleAssemblyOptionDefinition.docs.description, ): boolean { - return Option.Boolean(singleAssemblyOptionDefinition.optionFlags, false, { - description, - }) as unknown as boolean + return booleanOption(singleAssemblyOptionDefinition, description) } export function getConcurrencyOptionDocumentation( description = concurrencyOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return { - ...concurrencyOptionDefinition.docs, - description, - } + return getSharedCliOptionDocumentation(concurrencyOptionDefinition, description) } export function concurrencyOption( @@ -233,10 +223,7 @@ export function concurrencyOption( export function getPrintUrlsOptionDocumentation( description = printUrlsOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return { - ...printUrlsOptionDefinition.docs, - description, - } + return getSharedCliOptionDocumentation(printUrlsOptionDefinition, description) } export function printUrlsOption(description = printUrlsOptionDefinition.docs.description): boolean { diff --git a/packages/node/src/cli/generateIntentDocs.ts b/packages/node/src/cli/generateIntentDocs.ts index b575622c..4e786b1a 100644 --- a/packages/node/src/cli/generateIntentDocs.ts +++ b/packages/node/src/cli/generateIntentDocs.ts @@ -43,28 +43,15 @@ function renderTable(headers: string[], rows: string[][]): string { ].join('\n') } -function collapseWhitespace(value: string): string { - return value.replace(/\s+/g, ' ').trim() -} - -function stripMarkdownLinks(value: string): string { - return value.replace(/!?\[([^\]]+)\]\([^)]+\)/g, '$1') -} - -function stripHtml(value: string): string { - return value.replace(/<[^>]+>/g, ' ') -} - -function stripCodeBlocks(value: string): string { - return value.replace(/```[\s\S]*?```/g, ' ') -} - -function stripTemplateSyntax(value: string): string { - return value.replace(/\{\{[\s\S]*?\}\}/g, ' ') -} - -function stripInlineCode(value: string): string { - return value.replaceAll('`', '') +function sanitizeDocsMarkdown(value: string): string { + return value + .replace(/!?\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/\{\{[\s\S]*?\}\}/g, ' ') + .replaceAll('`', '') + .replace(/\s+/g, ' ') + .trim() } function truncateAtSentenceBoundary(value: string, maxLength: number): string { @@ -91,9 +78,7 @@ function summarizeDescription(value: string | undefined): string { return '—' } - const sanitized = collapseWhitespace( - stripInlineCode(stripTemplateSyntax(stripCodeBlocks(stripHtml(stripMarkdownLinks(value))))), - ) + const sanitized = sanitizeDocsMarkdown(value) if (sanitized.length === 0) { return '—' diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index f63191dc..5f5b5a81 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -3,6 +3,7 @@ import type { IntentDynamicStepExecutionDefinition, IntentOptionDefinition, } from '../intentRuntime.ts' +import { parseOptionalEnumValue, parseUniqueEnumArray } from './parsing.ts' const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const @@ -72,43 +73,19 @@ export const imageDescribeCommandPresentation = { function parseDescribeFields(value: string[] | undefined): ImageDescribeField[] { const rawFields = parseStringArrayValue(value ?? []) - - 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 + return parseUniqueEnumArray({ + flagName: '--fields', + supportedValues: imageDescribeFields, + values: rawFields, + }) } 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`) + return parseOptionalEnumValue({ + flagName: '--for', + supportedValues: ['wordpress'] as const, + value: profile, + }) } function resolveRequestedDescribeFields({ diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index 98e1364d..83e00fd7 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -4,35 +4,30 @@ import type { IntentOptionDefinition, IntentRunnerKind, } from '../intentRuntime.ts' +import { parseOptionalEnumValue } from './parsing.ts' const defaultMarkdownFormat = 'gfm' const defaultMarkdownTheme = 'github' +const markdownFormats = ['commonmark', 'gfm'] as const +const markdownThemes = ['bare', 'github'] as const function resolveMarkdownFormat(value: unknown): 'commonmark' | 'gfm' { - if (value == null || value === '') { - return defaultMarkdownFormat - } - - if (value === 'commonmark' || value === 'gfm') { - return value - } - - throw new Error( - `Unsupported --markdown-format value "${String(value)}". Supported values: commonmark, gfm`, + return ( + parseOptionalEnumValue({ + flagName: '--markdown-format', + supportedValues: markdownFormats, + value, + }) ?? defaultMarkdownFormat ) } function resolveMarkdownTheme(value: unknown): 'bare' | 'github' { - if (value == null || value === '') { - return defaultMarkdownTheme - } - - if (value === 'bare' || value === 'github') { - return value - } - - throw new Error( - `Unsupported --markdown-theme value "${String(value)}". Supported values: bare, github`, + return ( + parseOptionalEnumValue({ + flagName: '--markdown-theme', + supportedValues: markdownThemes, + value, + }) ?? defaultMarkdownTheme ) } diff --git a/packages/node/src/cli/semanticIntents/parsing.ts b/packages/node/src/cli/semanticIntents/parsing.ts new file mode 100644 index 00000000..6b78cdec --- /dev/null +++ b/packages/node/src/cli/semanticIntents/parsing.ts @@ -0,0 +1,56 @@ +export function parseOptionalEnumValue({ + flagName, + supportedValues, + value, +}: { + flagName: string + supportedValues: readonly TValue[] + value: unknown +}): TValue | null { + if (value == null || value === '') { + return null + } + + if (typeof value === 'string' && supportedValues.includes(value as TValue)) { + return value as TValue + } + + throw new Error( + `Unsupported ${flagName} value "${String(value)}". Supported values: ${supportedValues.join(', ')}`, + ) +} + +export function parseUniqueEnumArray({ + flagName, + supportedValues, + values, +}: { + flagName: string + supportedValues: readonly TValue[] + values: readonly string[] +}): TValue[] { + if (values.length === 0) { + return [] + } + + const parsedValues: TValue[] = [] + const seen = new Set() + + for (const value of values) { + if (!supportedValues.includes(value as TValue)) { + throw new Error( + `Unsupported ${flagName} value "${value}". Supported values: ${supportedValues.join(', ')}`, + ) + } + + const parsedValue = value as TValue + if (seen.has(parsedValue)) { + continue + } + + seen.add(parsedValue) + parsedValues.push(parsedValue) + } + + return parsedValues +} From a6578077a9cb3468befe666b518e3e75481dbc18 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 16:06:03 +0200 Subject: [PATCH 62/69] refactor(node): unify semantic intent descriptors --- packages/node/src/cli/intentCommands.ts | 40 ++++++++++++---- packages/node/src/cli/intentFields.ts | 23 ++++----- packages/node/src/cli/intentRuntime.ts | 26 ++++------ .../src/cli/semanticIntents/imageDescribe.ts | 31 +++++++----- .../node/src/cli/semanticIntents/index.ts | 27 ++++------- .../src/cli/semanticIntents/markdownPdf.ts | 48 +++++++------------ packages/node/src/inputFiles.ts | 3 +- 7 files changed, 97 insertions(+), 101 deletions(-) diff --git a/packages/node/src/cli/intentCommands.ts b/packages/node/src/cli/intentCommands.ts index be515cec..0fefffc7 100644 --- a/packages/node/src/cli/intentCommands.ts +++ b/packages/node/src/cli/intentCommands.ts @@ -341,14 +341,12 @@ function resolveRobotIntent(definition: RobotIntentDefinition): BuiltIntentComma const spec: BuiltIntentCommandDefinition = { className, 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\`.`, + details: getIntentDetails({ + defaultSingleAssembly: definition.defaultSingleAssembly === true, + inputMode, + outputMode, + robot: definition.robot, + }), examples: [], paths, runnerKind: @@ -378,6 +376,32 @@ function resolveRobotIntent(definition: RobotIntentDefinition): BuiltIntentComma } } +function getIntentDetails({ + defaultSingleAssembly, + inputMode, + outputMode, + robot, +}: { + defaultSingleAssembly: boolean + inputMode: IntentInputMode + outputMode: 'directory' | 'file' + robot: string +}): string { + if (inputMode === 'none') { + return `Runs \`${robot}\` and writes the result to \`--out\`.` + } + + if (defaultSingleAssembly) { + return `Runs \`${robot}\` for the provided inputs and writes the result to \`--out\`.` + } + + if (outputMode === 'directory') { + return `Runs \`${robot}\` on each input file and writes the results to \`--out\`.` + } + + return `Runs \`${robot}\` on each input file and writes the result to \`--out\`.` +} + function resolveSemanticIntent(definition: SemanticIntentDefinition): BuiltIntentCommandDefinition { const paths = getIntentPaths(definition) const descriptor = getSemanticIntentDescriptor(definition.semantic) diff --git a/packages/node/src/cli/intentFields.ts b/packages/node/src/cli/intentFields.ts index 2bb5574a..df5c2f8e 100644 --- a/packages/node/src/cli/intentFields.ts +++ b/packages/node/src/cli/intentFields.ts @@ -246,20 +246,15 @@ export function inferIntentExampleValue({ 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 preferredExamples = { + prompt: JSON.stringify('A red bicycle in a studio'), + provider: 'aws', + target_language: 'en-US', + voice: 'female-1', + } as const satisfies Record + const preferredExample = (preferredExamples as Record)[name] + if (preferredExample != null) { + return preferredExample } const schemaExample = diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 0b1cb0ea..cef9929f 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -138,22 +138,16 @@ function inferFilenameFromBase64Value(value: string, index: number): string { } const mediaType = trimmed.slice('data:'.length, markerIndex).split(';')[0]?.toLowerCase() ?? '' - const extension = - mediaType === 'text/plain' - ? 'txt' - : mediaType === 'text/markdown' - ? 'md' - : mediaType === 'application/pdf' - ? 'pdf' - : mediaType === 'image/png' - ? 'png' - : mediaType === 'image/jpeg' - ? 'jpg' - : mediaType === 'image/webp' - ? 'webp' - : mediaType === 'application/json' - ? 'json' - : 'bin' + const extensionByMediaType = { + 'text/plain': 'txt', + 'text/markdown': 'md', + 'application/pdf': 'pdf', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'application/json': 'json', + } as const satisfies Record + const extension = (extensionByMediaType as Record)[mediaType] ?? 'bin' return `input-base64-${index}.${extension}` } diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index 5f5b5a81..758ea99b 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -3,6 +3,7 @@ import type { IntentDynamicStepExecutionDefinition, IntentOptionDefinition, } from '../intentRuntime.ts' +import type { SemanticIntentDescriptor, SemanticIntentPresentation } from './index.ts' import { parseOptionalEnumValue, parseUniqueEnumArray } from './parsing.ts' const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const @@ -17,6 +18,12 @@ const wordpressDescribeFields = [ ] as const satisfies readonly ImageDescribeField[] const defaultDescribeModel = 'anthropic/claude-sonnet-4-6' +const describeFieldDescriptions = { + altText: 'A concise accessibility-focused alt text that objectively describes the image', + title: 'A concise publishable title for the image', + caption: 'A short caption suitable for displaying below the image', + description: 'A richer description of the image suitable for CMS usage', +} as const satisfies Record, string> export const imageDescribeExecutionDefinition = { kind: 'dynamic-step', @@ -69,7 +76,7 @@ export const imageDescribeCommandPresentation = { 'transloadit image describe --input hero.jpg --fields altText,title,caption --out fields.json', ], ] as Array<[string, string]>, -} as const +} as const satisfies SemanticIntentPresentation function parseDescribeFields(value: string[] | undefined): ImageDescribeField[] { const rawFields = parseStringArrayValue(value ?? []) @@ -103,7 +110,7 @@ function resolveRequestedDescribeFields({ return [...wordpressDescribeFields] } - return explicitFields.length === 0 ? ['labels'] : explicitFields + return ['labels'] } function validateDescribeFields({ @@ -153,20 +160,11 @@ function resolveImageDescribeRequest(rawValues: Record): { 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, + description: describeFieldDescriptions[field as Exclude], }, ] }), @@ -246,3 +244,12 @@ export function createImageDescribeStep( // switch this command to call that builtin instead of shipping prompt logic in the CLI. } } + +export const imageDescribeSemanticIntentDescriptor = { + createStep: createImageDescribeStep, + execution: imageDescribeExecutionDefinition, + inputPolicy: { kind: 'required' }, + outputDescription: 'Write the JSON result to this path or directory', + presentation: imageDescribeCommandPresentation, + runnerKind: 'watchable', +} as const satisfies SemanticIntentDescriptor diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts index 92dc40e3..897a0029 100644 --- a/packages/node/src/cli/semanticIntents/index.ts +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -4,16 +4,18 @@ import type { IntentRunnerKind, PreparedIntentInputs, } from '../intentRuntime.ts' -import { - createImageDescribeStep, - imageDescribeCommandPresentation, - imageDescribeExecutionDefinition, -} from './imageDescribe.ts' +import { imageDescribeSemanticIntentDescriptor } from './imageDescribe.ts' import { markdownDocxSemanticIntentDescriptor, markdownPdfSemanticIntentDescriptor, } from './markdownPdf.ts' +export interface SemanticIntentPresentation { + description: string + details: string + examples: Array<[string, string]> +} + export interface SemanticIntentDescriptor { createStep: (rawValues: Record) => Record execution: IntentDynamicStepExecutionDefinition @@ -23,23 +25,12 @@ export interface SemanticIntentDescriptor { preparedInputs: PreparedIntentInputs, rawValues: Record, ) => Promise - presentation: { - description: string - details: string - examples: Array<[string, string]> - } + presentation: SemanticIntentPresentation 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', - }, + 'image-describe': imageDescribeSemanticIntentDescriptor, 'markdown-pdf': { ...markdownPdfSemanticIntentDescriptor, }, diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index 83e00fd7..b3bb65d0 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -1,9 +1,7 @@ -import type { IntentInputPolicy } from '../intentInputPolicy.ts' import type { - IntentDynamicStepExecutionDefinition, IntentOptionDefinition, - IntentRunnerKind, } from '../intentRuntime.ts' +import type { SemanticIntentDescriptor, SemanticIntentPresentation } from './index.ts' import { parseOptionalEnumValue } from './parsing.ts' const defaultMarkdownFormat = 'gfm' @@ -50,19 +48,6 @@ const markdownOptionDefinitions = [ }, ] as const satisfies readonly IntentOptionDefinition[] -interface MarkdownConvertSemanticIntentDefinition { - createStep: (rawValues: Record) => Record - execution: IntentDynamicStepExecutionDefinition - inputPolicy: IntentInputPolicy - outputDescription: string - presentation: { - description: string - details: string - examples: Array<[string, string]> - } - runnerKind: IntentRunnerKind -} - function createMarkdownConvertSemanticIntent({ description, details, @@ -75,8 +60,22 @@ function createMarkdownConvertSemanticIntent({ exampleOutput: string format: 'docx' | 'pdf' handler: 'markdown-docx' | 'markdown-pdf' -}): MarkdownConvertSemanticIntentDefinition { +}): SemanticIntentDescriptor { const formatLabel = format.toUpperCase() + const presentation = { + description, + details, + examples: [ + [ + `Render a Markdown file as a ${formatLabel} file`, + `transloadit markdown ${format} --input README.md --out ${exampleOutput}`, + ], + [ + 'Print a temporary result URL without downloading locally', + `transloadit markdown ${format} --input README.md --print-urls`, + ], + ], + } satisfies SemanticIntentPresentation return { createStep(rawValues) { @@ -99,20 +98,7 @@ function createMarkdownConvertSemanticIntent({ }, inputPolicy: { kind: 'required' }, outputDescription: `Write the rendered ${formatLabel} to this path or directory`, - presentation: { - description, - details, - examples: [ - [ - `Render a Markdown file as a ${formatLabel} file`, - `transloadit markdown ${format} --input README.md --out ${exampleOutput}`, - ], - [ - 'Print a temporary result URL without downloading locally', - `transloadit markdown ${format} --input README.md --print-urls`, - ], - ], - }, + presentation, runnerKind: 'watchable', } } diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index c63fc5c1..febc343a 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -182,8 +182,7 @@ const isPrivateIp = (address: string): boolean => { return false } if (family === 6) { - const normalized = - normalizedAddress.toLowerCase().split('%')[0] ?? normalizedAddress.toLowerCase() + const normalized = normalizedAddress.toLowerCase().split('%')[0] if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true const mappedAddress = ipv4FromMappedIpv6(normalized) From b12d218fe8ba849044cf3d3a771bd158a72e5e1b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 16:33:45 +0200 Subject: [PATCH 63/69] fix(node): harden watch assembly output handling --- packages/node/README.md | 1 + packages/node/src/cli/commands/assemblies.ts | 57 +++++-- .../src/cli/semanticIntents/markdownPdf.ts | 4 +- .../test/unit/cli/assemblies-create.test.ts | 143 ++++++++++++++++++ 4 files changed, 192 insertions(+), 13 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index e7949029..9575d57e 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -1942,3 +1942,4 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo ## Development See [CONTRIBUTING](./CONTRIBUTING.md). + diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 30a8391c..7edfed74 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -340,6 +340,7 @@ interface OutputPlan { interface Job { inputPath: string | null out: OutputPlan | null + watchEvent?: boolean } type OutputPlanProvider = (inpath: string | null, indir?: string) => Promise @@ -429,10 +430,8 @@ async function createExistingPathOutputPlan(outputPath: string | undefined): Pro 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). if (inpath == null) { - return null + return await createExistingPathOutputPlan(output) } if (inpath === '-') { throw new Error('You must provide an input to output to a directory') @@ -1001,7 +1000,11 @@ class WatchJobEmitter extends MyEventEmitter { if (stats.isDirectory()) return const outputPlan = await outputPlanProvider(normalizedFile, topdir) - this.emit('job', { inputPath: getJobInputPath(normalizedFile), out: outputPlan }) + this.emit('job', { + inputPath: getJobInputPath(normalizedFile), + out: outputPlan, + watchEvent: true, + }) } } @@ -1059,6 +1062,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.watchEvent) { + emitter.emit('job', job) + return + } + if (job.inputPath == null || job.out == null) { emitter.emit('job', job) return @@ -1329,11 +1337,31 @@ export async function create( const results: unknown[] = [] const resultUrls: ResultUrlRow[] = [] const reservedResultPaths = new Set() + const latestWatchJobTokenByOutputPath = new Map() let hasFailures = false + let nextWatchJobToken = 0 // AbortController to cancel all in-flight createAssembly calls when an error occurs const abortController = new AbortController() const outputRootIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory()) + function reserveWatchJobToken(outputPath: string | null): number | null { + if (!watchOption || outputPath == null) { + return null + } + + const token = ++nextWatchJobToken + latestWatchJobTokenByOutputPath.set(outputPath, token) + return token + } + + function isSupersededWatchJob(outputPath: string | null, token: number | null): boolean { + if (!watchOption || outputPath == null || token == null) { + return false + } + + return latestWatchJobTokenByOutputPath.get(outputPath) !== token + } + function createAssemblyOptions({ files, uploads, @@ -1384,12 +1412,14 @@ export async function create( inPath, inputPaths, outputPlan, + outputToken, singleAssemblyMode, }: { createOptions: CreateAssemblyOptions inPath: string | null inputPaths: string[] outputPlan: OutputPlan | null + outputToken: number | null singleAssemblyMode?: boolean }): Promise { outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) @@ -1398,8 +1428,16 @@ export async function create( if (!assembly.results) throw new Error('No results in assembly') const normalizedResults = normalizeAssemblyResults(assembly.results) + if (isSupersededWatchJob(outputPlan?.path ?? null, outputToken)) { + outputctl.debug( + `SKIPPED SUPERSEDED WATCH RESULT ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`, + ) + return assembly + } + if ( !singleAssemblyMode && + !watchOption && (await shouldSkipStaleOutput({ inputPaths, outputPath: outputPlan?.path ?? null, @@ -1446,6 +1484,7 @@ export async function create( async function processAssemblyJob( inPath: string | null, outputPlan: OutputPlan | null, + outputToken: number | null, ): Promise { const files = inPath != null && inPath !== stdinWithPath.path @@ -1465,6 +1504,7 @@ export async function create( inPath, inputPaths: inPath == null ? [] : [inPath], outputPlan, + outputToken, }) } @@ -1491,11 +1531,6 @@ export async function create( }) emitter.on('end', async () => { - if (collectedPaths.length === 0 && inputlessOutputPlan == null) { - resolve({ resultUrls, results: [], hasFailures: false }) - return - } - if ( await shouldSkipStaleOutput({ inputPaths: collectedPaths, @@ -1548,6 +1583,7 @@ export async function create( outputPlan: inputlessOutputPlan ?? (resolvedOutput == null ? null : createOutputPlan(resolvedOutput, new Date(0))), + outputToken: null, singleAssemblyMode: true, }) }) @@ -1565,10 +1601,11 @@ export async function create( emitter.on('job', (job: Job) => { const inPath = job.inputPath const outputPlan = job.out + const outputToken = reserveWatchJobToken(outputPlan?.path ?? null) outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`) queue .add(async () => { - const result = await processAssemblyJob(inPath, outputPlan) + const result = await processAssemblyJob(inPath, outputPlan, outputToken) if (result !== undefined) { results.push(result) } diff --git a/packages/node/src/cli/semanticIntents/markdownPdf.ts b/packages/node/src/cli/semanticIntents/markdownPdf.ts index b3bb65d0..70691b24 100644 --- a/packages/node/src/cli/semanticIntents/markdownPdf.ts +++ b/packages/node/src/cli/semanticIntents/markdownPdf.ts @@ -1,6 +1,4 @@ -import type { - IntentOptionDefinition, -} from '../intentRuntime.ts' +import type { IntentOptionDefinition } from '../intentRuntime.ts' import type { SemanticIntentDescriptor, SemanticIntentPresentation } from './index.ts' import { parseOptionalEnumValue } from './parsing.ts' diff --git a/packages/node/test/unit/cli/assemblies-create.test.ts b/packages/node/test/unit/cli/assemblies-create.test.ts index 9ec70c6c..74460c5e 100644 --- a/packages/node/test/unit/cli/assemblies-create.test.ts +++ b/packages/node/test/unit/cli/assemblies-create.test.ts @@ -354,6 +354,49 @@ describe('assemblies create', () => { expect(await readFile(outputPath, 'utf8')).toBe('image-bytes') }) + it('runs valid inputless single-assembly steps when --out is a directory', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const tempDir = await createTempDir('transloadit-inputless-single-assembly-dir-') + const outputDir = path.join(tempDir, 'out') + await mkdir(outputDir, { recursive: true }) + + const output = new OutputCtl() + const client = { + createAssembly: vi.fn().mockResolvedValue({ assembly_id: 'assembly-inputless-single-dir' }), + awaitAssemblyCompletion: vi.fn().mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { + generated: [ + { url: 'http://downloads.test/generated-dir.png', name: 'generated-dir.png' }, + ], + }, + }), + } + + nock('http://downloads.test').get('/generated-dir.png').reply(200, 'dir-image-bytes') + + await create(output, client as never, { + inputs: [], + output: outputDir, + outputMode: 'directory', + singleAssembly: true, + stepsData: { + generated: { + robot: '/image/generate', + result: true, + prompt: 'hello', + model: 'google/nano-banana', + }, + }, + }) + + expect(client.createAssembly).toHaveBeenCalledTimes(1) + expect(await readFile(path.join(outputDir, 'generated-dir.png'), 'utf8')).toBe( + 'dir-image-bytes', + ) + }) + it('returns normalized step data from steps input parsing', () => { const parsed = parseStepsInputJson( JSON.stringify({ @@ -866,6 +909,106 @@ describe('assemblies create', () => { ) }) + it('does not let a newer watch job get skipped after an older watch result updates the output', 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-newer-skipped-') + 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-fast' }) + .mockResolvedValueOnce({ assembly_id: 'assembly-new-slow' }), + awaitAssemblyCompletion: vi.fn(async (assemblyId: string) => { + if (assemblyId === 'assembly-old-fast') { + await delay(40) + return { + ok: 'ASSEMBLY_COMPLETED', + results: { + thumbs: [{ url: 'http://downloads.test/old-fast.jpg', name: 'old-fast.jpg' }], + }, + } + } + + await delay(140) + return { + ok: 'ASSEMBLY_COMPLETED', + results: { + thumbs: [{ url: 'http://downloads.test/new-slow.jpg', name: 'new-slow.jpg' }], + }, + } + }), + } + + nock('http://downloads.test').get('/old-fast.jpg').reply(200, 'old-fast-result') + nock('http://downloads.test').get('/new-slow.jpg').reply(200, 'new-slow-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-slow-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) From 3b7cb79c9cbe7b567e52d73188e0d1081951b0a5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 16:42:33 +0200 Subject: [PATCH 64/69] docs(node): dedupe shared intent flags --- packages/node/README.md | 461 ++++++-------------- packages/node/docs/intent-commands.md | 459 ++++++------------- packages/node/src/cli/generateIntentDocs.ts | 82 ++-- 3 files changed, 303 insertions(+), 699 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 9575d57e..65ffff15 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -119,6 +119,47 @@ All intent commands also support the global CLI flags `--json`, `--log-level`, ` > At least one of `--out` or `--print-urls` is required on every intent command. +#### Shared flags + +These flags are available across many intent commands, so the per-command sections below focus on differences. + +**Shared file input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Shared no-input output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Shared processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | + +**Shared watch flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Shared bundling flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + #### `image generate` Generate images from text prompts @@ -138,6 +179,10 @@ npx transloadit image generate [options] - Execution: no input - Backend: `/image/generate` +**Shared flags** + +- Uses the shared output flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -152,13 +197,6 @@ npx transloadit image generate [options] | `--style` | `string` | no | `value` | Style of the generated image. | | `--num-outputs` | `number` | no | `1` | Number of image variants to generate. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - **Examples** ```bash @@ -184,6 +222,11 @@ npx transloadit preview generate --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/preview` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -213,26 +256,6 @@ npx transloadit preview generate --input [options] | `--clip-framerate` | `number` | no | `1` | 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… | | `--clip-loop` | `boolean` | no | `true` | Specifies whether the generated animated image should loop forever (true) or stop after playing the animation once (false). | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -258,6 +281,11 @@ npx transloadit image remove-background --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/bgremove` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -267,26 +295,6 @@ npx transloadit image remove-background --input [options] | `--provider` | `string` | no | `aws` | Provider to use for removing the background. | | `--model` | `string` | no | `value` | Provider-specific model to use for removing the background. Mostly intended for testing and evaluation. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -312,6 +320,11 @@ npx transloadit image optimize --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/optimize` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -321,26 +334,6 @@ npx transloadit image optimize --input [options] | `--preserve-meta-data` | `boolean` | no | `true` | Specifies if the image's metadata should be preserved during the optimization, or not. | | `--fix-breaking-images` | `boolean` | no | `true` | If set to true this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies . | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -366,6 +359,11 @@ npx transloadit image resize --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/resize` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -416,26 +414,6 @@ npx transloadit image resize --input [options] | `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | | `--shave` | `auto` | no | `value` | Shave pixels from the image edges. The value should be in the format width or widthxheight to specify the number of pixels to remove from each side. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -461,6 +439,11 @@ npx transloadit document convert --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/convert` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -475,26 +458,6 @@ npx transloadit document convert --input [options] | `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - date formatted print date - title document… | | `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the pdf_header_template. Currently this parameter is only supported when converting from html, and requires… | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -520,6 +483,11 @@ npx transloadit document optimize --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/optimize` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -532,26 +500,6 @@ npx transloadit document optimize --input [options] | `--linearize` | `boolean` | no | `true` | Whether to linearize (optimize for Fast Web View) the output PDF. | | `--compatibility` | `string` | no | `1.4` | 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… | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -577,25 +525,10 @@ npx transloadit document auto-rotate --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/autorotate` -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** +**Shared flags** -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. **Examples** @@ -622,6 +555,11 @@ npx transloadit document thumbs --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/thumbs` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -641,26 +579,6 @@ npx transloadit document thumbs --input [options] | `--pdf-use-cropbox` | `boolean` | no | `true` | 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… | | `--turbo` | `boolean` | no | `true` | If you set this to false, the robot will not emit files as they become available. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -686,6 +604,11 @@ npx transloadit audio waveform --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/audio/waveform` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -717,26 +640,6 @@ npx transloadit audio waveform --input [options] | `--amplitude-scale` | `number` | no | `1` | Available when style is "v1". Amplitude scale factor. | | `--compression` | `number` | no | `1` | Available when style is "v1". PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -762,6 +665,11 @@ npx transloadit text speak --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/text/speak` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -772,26 +680,6 @@ npx transloadit text speak --input [options] | `--voice` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | | `--ssml` | `boolean` | no | `true` | Supply Speech Synthesis Markup Language instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -817,6 +705,11 @@ npx transloadit video thumbs --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/video/thumbs` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -832,26 +725,6 @@ npx transloadit video thumbs --input [options] | `--rotate` | `number` | no | `0` | Forces the video to be rotated by the specified degree integer. | | `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -877,25 +750,10 @@ npx transloadit video encode-hls --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `builtin/encode-hls-video@latest` -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | +**Shared flags** -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. **Examples** @@ -922,6 +780,11 @@ npx transloadit image describe --input [options] - Execution: per-file; supports `--watch` - Backend: semantic alias `image-describe` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -930,25 +793,6 @@ npx transloadit image describe --input [options] | `--for` | `string` | no | — | Use a named output profile, currently: wordpress | | `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-4-6) | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the JSON result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | - **Examples** ```bash @@ -979,6 +823,11 @@ npx transloadit markdown pdf --input [options] - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-pdf` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -986,25 +835,6 @@ npx transloadit markdown pdf --input [options] | `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | | `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the rendered PDF to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | - **Examples** ```bash @@ -1033,6 +863,11 @@ npx transloadit markdown docx --input [options] - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-docx` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -1040,25 +875,6 @@ npx transloadit markdown docx --input [options] | `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | | `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the rendered DOCX to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | - **Examples** ```bash @@ -1087,6 +903,11 @@ npx transloadit file compress --input [options] - Execution: single assembly - Backend: `/file/compress` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -1098,23 +919,6 @@ npx transloadit file compress --input [options] | `--file-layout` | `string` | no | `advanced` | 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… | | `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | - **Examples** ```bash @@ -1140,25 +944,10 @@ npx transloadit file decompress --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/decompress` -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** +**Shared flags** -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. **Examples** @@ -1943,3 +1732,5 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo See [CONTRIBUTING](./CONTRIBUTING.md). + + diff --git a/packages/node/docs/intent-commands.md b/packages/node/docs/intent-commands.md index b8d68606..409373eb 100644 --- a/packages/node/docs/intent-commands.md +++ b/packages/node/docs/intent-commands.md @@ -31,6 +31,47 @@ All intent commands also support the global CLI flags `--json`, `--log-level`, ` > At least one of `--out` or `--print-urls` is required on every intent command. +## Shared flags + +These flags are available across many intent commands, so the per-command sections below focus on differences. + +**Shared file input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Shared no-input output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Shared processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | + +**Shared watch flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Shared bundling flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + ## `image generate` Generate images from text prompts @@ -50,6 +91,10 @@ npx transloadit image generate [options] - Execution: no input - Backend: `/image/generate` +**Shared flags** + +- Uses the shared output flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -64,13 +109,6 @@ npx transloadit image generate [options] | `--style` | `string` | no | `value` | Style of the generated image. | | `--num-outputs` | `number` | no | `1` | Number of image variants to generate. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - **Examples** ```bash @@ -96,6 +134,11 @@ npx transloadit preview generate --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/preview` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -125,26 +168,6 @@ npx transloadit preview generate --input [options] | `--clip-framerate` | `number` | no | `1` | 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… | | `--clip-loop` | `boolean` | no | `true` | Specifies whether the generated animated image should loop forever (true) or stop after playing the animation once (false). | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -170,6 +193,11 @@ npx transloadit image remove-background --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/bgremove` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -179,26 +207,6 @@ npx transloadit image remove-background --input [options] | `--provider` | `string` | no | `aws` | Provider to use for removing the background. | | `--model` | `string` | no | `value` | Provider-specific model to use for removing the background. Mostly intended for testing and evaluation. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -224,6 +232,11 @@ npx transloadit image optimize --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/optimize` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -233,26 +246,6 @@ npx transloadit image optimize --input [options] | `--preserve-meta-data` | `boolean` | no | `true` | Specifies if the image's metadata should be preserved during the optimization, or not. | | `--fix-breaking-images` | `boolean` | no | `true` | If set to true this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies . | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -278,6 +271,11 @@ npx transloadit image resize --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/image/resize` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -328,26 +326,6 @@ npx transloadit image resize --input [options] | `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | | `--shave` | `auto` | no | `value` | Shave pixels from the image edges. The value should be in the format width or widthxheight to specify the number of pixels to remove from each side. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -373,6 +351,11 @@ npx transloadit document convert --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/convert` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -387,26 +370,6 @@ npx transloadit document convert --input [options] | `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - date formatted print date - title document… | | `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the pdf_header_template. Currently this parameter is only supported when converting from html, and requires… | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -432,6 +395,11 @@ npx transloadit document optimize --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/optimize` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -444,26 +412,6 @@ npx transloadit document optimize --input [options] | `--linearize` | `boolean` | no | `true` | Whether to linearize (optimize for Fast Web View) the output PDF. | | `--compatibility` | `string` | no | `1.4` | 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… | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -489,25 +437,10 @@ npx transloadit document auto-rotate --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/autorotate` -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** +**Shared flags** -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. **Examples** @@ -534,6 +467,11 @@ npx transloadit document thumbs --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/document/thumbs` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -553,26 +491,6 @@ npx transloadit document thumbs --input [options] | `--pdf-use-cropbox` | `boolean` | no | `true` | 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… | | `--turbo` | `boolean` | no | `true` | If you set this to false, the robot will not emit files as they become available. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -598,6 +516,11 @@ npx transloadit audio waveform --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/audio/waveform` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -629,26 +552,6 @@ npx transloadit audio waveform --input [options] | `--amplitude-scale` | `number` | no | `1` | Available when style is "v1". Amplitude scale factor. | | `--compression` | `number` | no | `1` | Available when style is "v1". PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -674,6 +577,11 @@ npx transloadit text speak --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/text/speak` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -684,26 +592,6 @@ npx transloadit text speak --input [options] | `--voice` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | | `--ssml` | `boolean` | no | `true` | Supply Speech Synthesis Markup Language instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -729,6 +617,11 @@ npx transloadit video thumbs --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/video/thumbs` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -744,26 +637,6 @@ npx transloadit video thumbs --input [options] | `--rotate` | `number` | no | `0` | Forces the video to be rotated by the specified degree integer. | | `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | - **Examples** ```bash @@ -789,25 +662,10 @@ npx transloadit video encode-hls --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `builtin/encode-hls-video@latest` -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** +**Shared flags** -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. **Examples** @@ -834,6 +692,11 @@ npx transloadit image describe --input [options] - Execution: per-file; supports `--watch` - Backend: semantic alias `image-describe` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -842,25 +705,6 @@ npx transloadit image describe --input [options] | `--for` | `string` | no | — | Use a named output profile, currently: wordpress | | `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-4-6) | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the JSON result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | - **Examples** ```bash @@ -891,6 +735,11 @@ npx transloadit markdown pdf --input [options] - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-pdf` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -898,25 +747,6 @@ npx transloadit markdown pdf --input [options] | `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | | `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the rendered PDF to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | - **Examples** ```bash @@ -945,6 +775,11 @@ npx transloadit markdown docx --input [options] - Execution: per-file; supports `--watch` - Backend: semantic alias `markdown-docx` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -952,25 +787,6 @@ npx transloadit markdown docx --input [options] | `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | | `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the rendered DOCX to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | - **Examples** ```bash @@ -999,6 +815,11 @@ npx transloadit file compress --input [options] - Execution: single assembly - Backend: `/file/compress` +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags listed above. + **Command options** | Flag | Type | Required | Example | Description | @@ -1010,23 +831,6 @@ npx transloadit file compress --input [options] | `--file-layout` | `string` | no | `advanced` | 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… | | `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | - **Examples** ```bash @@ -1052,25 +856,10 @@ npx transloadit file decompress --input [options] - Execution: per-file; supports `--single-assembly` and `--watch` - Backend: `/file/decompress` -**Input & output flags** - -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | -| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | -| `--out, -o` | `directory` | yes* | `output/` | Write the results to this directory | -| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | - -**Processing flags** +**Shared flags** -| Flag | Type | Required | Example | Description | -| --- | --- | --- | --- | --- | -| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | -| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | -| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | -| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | -| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | -| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. **Examples** diff --git a/packages/node/src/cli/generateIntentDocs.ts b/packages/node/src/cli/generateIntentDocs.ts index 4e786b1a..878f18b3 100644 --- a/packages/node/src/cli/generateIntentDocs.ts +++ b/packages/node/src/cli/generateIntentDocs.ts @@ -169,56 +169,69 @@ function getCommandOptionRows(definition: ResolvedIntentCommandDefinition): DocO })) } -function getInputOutputRows(definition: ResolvedIntentCommandDefinition): DocOptionRow[] { - const outputType = definition.intentDefinition.outputMode === 'directory' ? 'directory' : 'path' - - if (definition.runnerKind === 'no-input') { - return [ - { - flags: '--out, -o', - type: outputType, - required: 'yes*', - example: definition.intentDefinition.outputMode === 'directory' ? 'output/' : 'output.file', - description: definition.intentDefinition.outputDescription, - }, - getPrintUrlsOptionDocumentation(), - ] - } - +function getSharedFileInputOutputRows(): DocOptionRow[] { return [ getInputPathsOptionDocumentation(), getInputBase64OptionDocumentation(), { flags: '--out, -o', - type: outputType, + type: 'path', required: 'yes*', - example: definition.intentDefinition.outputMode === 'directory' ? 'output/' : 'output.file', - description: definition.intentDefinition.outputDescription, + example: 'output.file', + description: 'Write the result to this path or directory', }, getPrintUrlsOptionDocumentation(), ] } -function getProcessingRows(definition: ResolvedIntentCommandDefinition): DocOptionRow[] { - if (definition.runnerKind === 'no-input') { - return [] - } +function getSharedNoInputOutputRows(): DocOptionRow[] { + return [ + { + flags: '--out, -o', + type: 'path', + required: 'yes*', + example: 'output.file', + description: 'Write the result to this path', + }, + getPrintUrlsOptionDocumentation(), + ] +} - const rows: DocOptionRow[] = [ +function getSharedProcessingRows(): DocOptionRow[] { + return [ getRecursiveOptionDocumentation(), getDeleteAfterProcessingOptionDocumentation(), getReprocessStaleOptionDocumentation(), ] +} + +function getSharedWatchRows(): DocOptionRow[] { + return [getWatchOptionDocumentation(), getConcurrencyOptionDocumentation()] +} + +function getSharedBundlingRows(): DocOptionRow[] { + return [getSingleAssemblyOptionDocumentation()] +} + +function getSharedFlagSupportNotes(definition: ResolvedIntentCommandDefinition): string[] { + if (definition.runnerKind === 'no-input') { + return ['Uses the shared output flags listed above.'] + } + + const notes = ['Uses the shared file input and output flags listed above.'] + const processingGroups = ['base processing flags'] if (definition.runnerKind === 'standard' || definition.runnerKind === 'watchable') { - rows.push(getWatchOptionDocumentation(), getConcurrencyOptionDocumentation()) + processingGroups.push('watch flags') } if (definition.runnerKind === 'standard') { - rows.push(getSingleAssemblyOptionDocumentation()) + processingGroups.push('bundling flags') } - return rows + notes.push(`Also supports the shared ${processingGroups.join(', ')} listed above.`) + + return notes } function renderOptionSection(title: string, rows: DocOptionRow[]): string[] { @@ -283,9 +296,11 @@ function renderIntentSection( `- Execution: ${getExecutionSummary(definition)}`, `- Backend: ${getBackendSummary(definition.catalogDefinition)}`, '', + '**Shared flags**', + '', + ...getSharedFlagSupportNotes(definition).map((note) => `- ${note}`), + '', ...renderOptionSection('Command options', getCommandOptionRows(definition)), - ...renderOptionSection('Input & output flags', getInputOutputRows(definition)), - ...renderOptionSection('Processing flags', getProcessingRows(definition)), '**Examples**', '', renderExamples(definition.examples), @@ -326,6 +341,15 @@ function renderIntentDocsBody({ '', '> At least one of `--out` or `--print-urls` is required on every intent command.', '', + `${heading} Shared flags`, + '', + 'These flags are available across many intent commands, so the per-command sections below focus on differences.', + '', + ...renderOptionSection('Shared file input & output flags', getSharedFileInputOutputRows()), + ...renderOptionSection('Shared no-input output flags', getSharedNoInputOutputRows()), + ...renderOptionSection('Shared processing flags', getSharedProcessingRows()), + ...renderOptionSection('Shared watch flags', getSharedWatchRows()), + ...renderOptionSection('Shared bundling flags', getSharedBundlingRows()), ] for (const definition of definitions) { From 1def473065316e6a493ef6391db8f483f2e91f12 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 17:04:51 +0200 Subject: [PATCH 65/69] fix(node): restore full intent smoke coverage --- packages/node/README.md | 3 ++- packages/node/docs/intent-commands.md | 2 +- packages/node/scripts/test-intents-e2e.sh | 20 +++++++++++-------- .../src/cli/semanticIntents/imageDescribe.ts | 5 +++-- packages/node/test/unit/cli/intents.test.ts | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 65ffff15..b48854c1 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -791,7 +791,7 @@ npx transloadit image describe --input [options] | --- | --- | --- | --- | --- | | `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | | `--for` | `string` | no | — | Use a named output profile, currently: wordpress | -| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-4-6) | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514) | **Examples** @@ -1734,3 +1734,4 @@ See [CONTRIBUTING](./CONTRIBUTING.md). + diff --git a/packages/node/docs/intent-commands.md b/packages/node/docs/intent-commands.md index 409373eb..b40c81b9 100644 --- a/packages/node/docs/intent-commands.md +++ b/packages/node/docs/intent-commands.md @@ -703,7 +703,7 @@ npx transloadit image describe --input [options] | --- | --- | --- | --- | --- | | `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | | `--for` | `string` | no | — | Use a named output profile, currently: wordpress | -| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-4-6) | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514) | **Examples** diff --git a/packages/node/scripts/test-intents-e2e.sh b/packages/node/scripts/test-intents-e2e.sh index 434d0ffe..5099ea96 100755 --- a/packages/node/scripts/test-intents-e2e.sh +++ b/packages/node/scripts/test-intents-e2e.sh @@ -79,7 +79,7 @@ verify_pdf() { } verify_docx() { - verify_file_type "$1" 'Microsoft Word 2007+' + verify_file_type "$1" 'Microsoft OOXML' } verify_mp3() { @@ -91,19 +91,23 @@ verify_zip() { } verify_document_thumbs() { - [[ -f "$1/in.png" ]] || return 1 - verify_png "$1/in.png" + local first_png + first_png="$(find "$1" -maxdepth 1 -type f -name '*.png' | sort | head -n 1)" + [[ -n "$first_png" ]] || return 1 + verify_png "$first_png" } verify_video_thumbs() { - [[ -f "$1/in_0.jpg" ]] || return 1 - verify_jpeg "$1/in_0.jpg" + local first_jpeg + first_jpeg="$(find "$1" -maxdepth 1 -type f -name '*.jpg' | sort | head -n 1)" + [[ -n "$first_jpeg" ]] || return 1 + verify_jpeg "$first_jpeg" } 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/high/input.mp4" ]] || return 1 + [[ -f "$1/low/input.mp4" ]] || return 1 + [[ -f "$1/mid/input.mp4" ]] || return 1 [[ -f "$1/adaptive/my_playlist.m3u8" ]] || return 1 } diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index 758ea99b..62611d79 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -17,7 +17,7 @@ const wordpressDescribeFields = [ 'description', ] as const satisfies readonly ImageDescribeField[] -const defaultDescribeModel = 'anthropic/claude-sonnet-4-6' +const defaultDescribeModel = 'anthropic/claude-4-sonnet-20250514' const describeFieldDescriptions = { altText: 'A concise accessibility-focused alt text that objectively describes the image', title: 'A concise publishable title for the image', @@ -52,7 +52,8 @@ export const imageDescribeExecutionDefinition = { kind: 'string', propertyName: 'model', optionFlags: '--model', - description: 'Model to use for generated text fields (default: anthropic/claude-sonnet-4-6)', + 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 abbe4a9b..56e8d3cb 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -218,7 +218,7 @@ describe('intent commands', () => { robot: '/ai/chat', use: ':original', result: true, - model: 'anthropic/claude-sonnet-4-6', + model: 'anthropic/claude-4-sonnet-20250514', format: 'json', return_messages: 'last', test_credentials: true, From c44b1832a8b8e2c96034ed30450fa3dbceaede46 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 18:39:17 +0200 Subject: [PATCH 66/69] refactor(node): share cli option helpers --- .../node/src/cli/fileProcessingOptions.ts | 58 +++++++++++++++---- packages/node/src/cli/intentRuntime.ts | 27 ++++++--- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/node/src/cli/fileProcessingOptions.ts b/packages/node/src/cli/fileProcessingOptions.ts index 6029ed75..0dd4716a 100644 --- a/packages/node/src/cli/fileProcessingOptions.ts +++ b/packages/node/src/cli/fileProcessingOptions.ts @@ -14,6 +14,11 @@ interface SharedCliOptionDefinition { optionFlags: string } +interface SharedCliOptionExports { + docs: (description?: string) => SharedCliOptionDocumentation + option: (description?: string) => T +} + export interface SharedFileProcessingValidationInput { explicitInputCount: number singleAssembly: boolean @@ -137,72 +142,101 @@ function booleanOption( }) as unknown as boolean } +function createArrayOptionExports( + definition: SharedCliOptionDefinition, +): SharedCliOptionExports { + return { + docs: (description = definition.docs.description) => + getSharedCliOptionDocumentation(definition, description), + option: (description = definition.docs.description) => arrayOption(definition, description), + } +} + +function createBooleanOptionExports( + definition: SharedCliOptionDefinition, +): SharedCliOptionExports { + return { + docs: (description = definition.docs.description) => + getSharedCliOptionDocumentation(definition, description), + option: (description = definition.docs.description) => booleanOption(definition, description), + } +} + +const inputPathsOptionExports = createArrayOptionExports(inputPathsOptionDefinition) +const recursiveOptionExports = createBooleanOptionExports(recursiveOptionDefinition) +const deleteAfterProcessingOptionExports = createBooleanOptionExports( + deleteAfterProcessingOptionDefinition, +) +const reprocessStaleOptionExports = createBooleanOptionExports(reprocessStaleOptionDefinition) +const watchOptionExports = createBooleanOptionExports(watchOptionDefinition) +const singleAssemblyOptionExports = createBooleanOptionExports(singleAssemblyOptionDefinition) + export function getInputPathsOptionDocumentation( description = inputPathsOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return getSharedCliOptionDocumentation(inputPathsOptionDefinition, description) + return inputPathsOptionExports.docs(description) } export function inputPathsOption( description = inputPathsOptionDefinition.docs.description, ): string[] { - return arrayOption(inputPathsOptionDefinition, description) + return inputPathsOptionExports.option(description) } export function getRecursiveOptionDocumentation( description = recursiveOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return getSharedCliOptionDocumentation(recursiveOptionDefinition, description) + return recursiveOptionExports.docs(description) } export function recursiveOption(description = recursiveOptionDefinition.docs.description): boolean { - return booleanOption(recursiveOptionDefinition, description) + return recursiveOptionExports.option(description) } export function getDeleteAfterProcessingOptionDocumentation( description = deleteAfterProcessingOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return getSharedCliOptionDocumentation(deleteAfterProcessingOptionDefinition, description) + return deleteAfterProcessingOptionExports.docs(description) } export function deleteAfterProcessingOption( description = deleteAfterProcessingOptionDefinition.docs.description, ): boolean { - return booleanOption(deleteAfterProcessingOptionDefinition, description) + return deleteAfterProcessingOptionExports.option(description) } export function getReprocessStaleOptionDocumentation( description = reprocessStaleOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return getSharedCliOptionDocumentation(reprocessStaleOptionDefinition, description) + return reprocessStaleOptionExports.docs(description) } export function reprocessStaleOption( description = reprocessStaleOptionDefinition.docs.description, ): boolean { - return booleanOption(reprocessStaleOptionDefinition, description) + return reprocessStaleOptionExports.option(description) } export function getWatchOptionDocumentation( description = watchOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return getSharedCliOptionDocumentation(watchOptionDefinition, description) + return watchOptionExports.docs(description) } export function watchOption(description = watchOptionDefinition.docs.description): boolean { - return booleanOption(watchOptionDefinition, description) + return watchOptionExports.option(description) } export function getSingleAssemblyOptionDocumentation( description = singleAssemblyOptionDefinition.docs.description, ): SharedCliOptionDocumentation { - return getSharedCliOptionDocumentation(singleAssemblyOptionDefinition, description) + return singleAssemblyOptionExports.docs(description) } export function singleAssemblyOption( description = singleAssemblyOptionDefinition.docs.description, ): boolean { - return booleanOption(singleAssemblyOptionDefinition, description) + return singleAssemblyOptionExports.option(description) } export function getConcurrencyOptionDocumentation( diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index cef9929f..123c2e28 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -118,26 +118,34 @@ function isHttpUrl(value: string): boolean { } } -function normalizeBase64Value(value: string): string { +function parseBase64DataUrl( + value: string, +): { mediaType: string | null; payload: string; trimmed: string } | null { const trimmed = value.trim() const marker = ';base64,' const markerIndex = trimmed.indexOf(marker) if (!trimmed.startsWith('data:') || markerIndex === -1) { - return trimmed + return null } - return trimmed.slice(markerIndex + marker.length) + return { + trimmed, + mediaType: trimmed.slice('data:'.length, markerIndex).split(';')[0]?.toLowerCase() ?? null, + payload: trimmed.slice(markerIndex + marker.length), + } +} + +function normalizeBase64Value(value: string): string { + const parsed = parseBase64DataUrl(value) + return parsed?.payload ?? value.trim() } function inferFilenameFromBase64Value(value: string, index: number): string { - const trimmed = value.trim() - const marker = ';base64,' - const markerIndex = trimmed.indexOf(marker) - if (!trimmed.startsWith('data:') || markerIndex === -1) { + const parsed = parseBase64DataUrl(value) + if (parsed == null) { return `input-base64-${index}.bin` } - const mediaType = trimmed.slice('data:'.length, markerIndex).split(';')[0]?.toLowerCase() ?? '' const extensionByMediaType = { 'text/plain': 'txt', 'text/markdown': 'md', @@ -147,7 +155,8 @@ function inferFilenameFromBase64Value(value: string, index: number): string { 'image/webp': 'webp', 'application/json': 'json', } as const satisfies Record - const extension = (extensionByMediaType as Record)[mediaType] ?? 'bin' + const extension = + (extensionByMediaType as Record)[parsed.mediaType ?? ''] ?? 'bin' return `input-base64-${index}.${extension}` } From b192af545c7dc1813b8ddaae24485abb566a2e39 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 19:15:31 +0200 Subject: [PATCH 67/69] fix(node): restore verify and parity --- docs/fingerprint/transloadit-baseline.json | 497 ++++++++++++------ .../transloadit-baseline.package.json | 4 +- knip.ts | 1 + packages/node/package.json | 1 + packages/node/src/cli/commands/assemblies.ts | 2 +- packages/node/src/cli/intentCommandSpecs.ts | 4 - packages/node/src/cli/intentRuntime.ts | 8 +- packages/node/src/cli/resultFiles.ts | 2 +- .../src/cli/semanticIntents/imageDescribe.ts | 8 +- .../node/src/cli/semanticIntents/index.ts | 2 +- .../test/e2e/cli/assemblies-create.test.ts | 15 +- packages/transloadit/package.json | 4 +- 12 files changed, 360 insertions(+), 188 deletions(-) diff --git a/docs/fingerprint/transloadit-baseline.json b/docs/fingerprint/transloadit-baseline.json index cba9f986..e7f329a2 100644 --- a/docs/fingerprint/transloadit-baseline.json +++ b/docs/fingerprint/transloadit-baseline.json @@ -2,8 +2,8 @@ "packageDir": "packages/transloadit", "tarball": { "filename": "transloadit-4.7.5.tgz", - "sizeBytes": 1338690, - "sha256": "82b176af124eca81eec440520d3ca4b68525b257b1dd59e3f1a4333e62e26e9e" + "sizeBytes": 1329164, + "sha256": "0be45e33a585acb5648bea70b10ef6c8371b5952a890fe378739172338d43a03" }, "packageJson": { "name": "transloadit", @@ -33,8 +33,8 @@ }, { "path": "dist/alphalib/types/robots/ai-chat.js", - "sizeBytes": 9598, - "sha256": "450227323009921c8c436c4ca3d325846d4ccab7e47a5a29b94ebefd93170492" + "sizeBytes": 9661, + "sha256": "518b0b96173e89cc8f045a068b9ed080256d73f6bdbd486350773650026cbfc3" }, { "path": "dist/ApiError.js", @@ -48,8 +48,8 @@ }, { "path": "dist/cli/commands/assemblies.js", - "sizeBytes": 50297, - "sha256": "c88802ee5f259357a626addd8e8602c39672bbb1e658515e956db8ad09934fb5" + "sizeBytes": 53557, + "sha256": "733b23ad1c8c65a9be218fdb0458d7b90da4ada9304198ab0935cf5f4b71f4ae" }, { "path": "dist/alphalib/types/assembliesGet.js", @@ -178,8 +178,8 @@ }, { "path": "dist/cli.js", - "sizeBytes": 1147, - "sha256": "4d52d0cea6f64abe67fd99d9bdf14dee38a51ee9c366eb45f110f38ab008f4dd" + "sizeBytes": 1219, + "sha256": "b959001b789f7ebd47577de52ea067ea8115a2710b678f53bd479fadaba75aed" }, { "path": "dist/alphalib/types/robots/cloudfiles-import.js", @@ -266,6 +266,11 @@ "sizeBytes": 1993, "sha256": "268fedb80d76c3a8406f6ea79ee453ce1b821dc9bdc2bc92977e429aeca9bcc2" }, + { + "path": "dist/ensureUniqueCounter.js", + "sizeBytes": 1431, + "sha256": "1066bcc2369c0c784d05428a1bb22b670b9be6241ddb8e1b69606c9ba3ed3266" + }, { "path": "dist/alphalib/types/robots/file-compress.js", "sizeBytes": 6014, @@ -318,8 +323,8 @@ }, { "path": "dist/cli/fileProcessingOptions.js", - "sizeBytes": 1907, - "sha256": "dcc0a2470ca0003901ab4fc24f033f27f3b3e2fe7db131a166b20efe29568b59" + "sizeBytes": 7306, + "sha256": "8291853fd88b397a758b81053668dfb728d07312840657c43af15b7434087901" }, { "path": "dist/alphalib/types/robots/ftp-import.js", @@ -332,9 +337,9 @@ "sha256": "c4bd648bb097acadbc349406192105367b9d94c516700b99c9f4d7a4b6c7a6f0" }, { - "path": "dist/cli/commands/generated-intents.js", - "sizeBytes": 86298, - "sha256": "f64a7238d2954d1ff71ab02be0d2f18d1dd048e3543cd9a79950794fdbdcd365" + "path": "dist/cli/generateIntentDocs.js", + "sizeBytes": 11641, + "sha256": "b9b9bf05020ff6c452c3c3fd8b878fba73d4d49cf6ba77714ec0cfad6e763c17" }, { "path": "dist/alphalib/types/robots/google-import.js", @@ -401,6 +406,11 @@ "sizeBytes": 27934, "sha256": "7683dca61e77618aad347431b7693fac282d208526dde351ba86387a53c962f4" }, + { + "path": "dist/cli/semanticIntents/imageDescribe.js", + "sizeBytes": 7347, + "sha256": "1ac1b94f250a4c9cf8b37cffaebe49ed0a994ad622a390cf967e9f5858fc13a1" + }, { "path": "dist/InconsistentResponseError.js", "sizeBytes": 158, @@ -408,43 +418,43 @@ }, { "path": "dist/cli/commands/index.js", - "sizeBytes": 2312, - "sha256": "a11ca4773963c91d8d03123b9e2e7a2a5d268880e1bae18f0419df9a36adfb26" + "sizeBytes": 2310, + "sha256": "a60cff637a0113cfbbf64ff93eb3fa00da81119dae258555326b9cde940556dc" + }, + { + "path": "dist/cli/semanticIntents/index.js", + "sizeBytes": 712, + "sha256": "b7edabdaa145ba3ebb0e785290303cdceb954645e146855585456e4cf6fafcda" }, { "path": "dist/inputFiles.js", - "sizeBytes": 7836, - "sha256": "1d77d129abc1b11be894d1cf6c34afc93370165e39871d6d5b672c058d1a0489" + "sizeBytes": 14263, + "sha256": "a7b721275494cf4abc2d86ef85fd080a94e9a6d35d58e219d43f72d568a87c0d" + }, + { + "path": "dist/cli/intentCommands.js", + "sizeBytes": 13030, + "sha256": "25599f79eab593c96a55ca216bdb46ae02f8a45f6abe66ba79c7c747db2adfaf" }, { "path": "dist/cli/intentCommandSpecs.js", - "sizeBytes": 6595, - "sha256": "19fc06131e457c60d77d46fcfbf970855849b08b16f76ee76fa65f2188dc9c4c" + "sizeBytes": 7079, + "sha256": "f80fb2fda1b9c0c1cb49f132a5a03aeec51c91961d54973f117403a18547407e" }, { "path": "dist/cli/intentFields.js", - "sizeBytes": 3431, - "sha256": "dd72c1bbbb64be5b3f346803935060707b203d364060d7fc10a44b063eb6110c" + "sizeBytes": 9567, + "sha256": "5a35c9900a09529475d844144658bd801d0d5c50ef95eee0310947b72d6d16a0" }, { "path": "dist/cli/intentInputPolicy.js", "sizeBytes": 56, "sha256": "f2dfdc05ddec25bf8ae63448d8e562ff7ba6ec3b17b4ea4be0adb151017c5991" }, - { - "path": "dist/cli/intentResolvedDefinitions.js", - "sizeBytes": 12204, - "sha256": "1caadb7700937def4eb86539f8e8f12f4bc532f9ab944368ffd2ffcef527ce6b" - }, { "path": "dist/cli/intentRuntime.js", - "sizeBytes": 10990, - "sha256": "e2e2ef1c92038c176922a69db8c1637fb97228a4cbb08057d27b31a33121a074" - }, - { - "path": "dist/cli/intentSmokeCases.js", - "sizeBytes": 3072, - "sha256": "01e0f5f7d57c1fbb697b9ce1cd599b375cbe2b0414c565f2e6e7a957d470df9d" + "sizeBytes": 16866, + "sha256": "1b3f1cd84f162f33e60b7416a4f0be0cbab9d5840ed5f44bec4d7d239e4e9eeb" }, { "path": "dist/lintAssemblyInput.js", @@ -456,6 +466,11 @@ "sizeBytes": 1546, "sha256": "561ba7f86c96d2481fc21ae81be056adf34af5b6deea434f0993b889927fbf89" }, + { + "path": "dist/cli/semanticIntents/markdownPdf.js", + "sizeBytes": 3562, + "sha256": "ddbba834eeb5c592c44a525781ae951ea5e9e3588b855161d42c1a42730b18e3" + }, { "path": "dist/alphalib/mcache.js", "sizeBytes": 5145, @@ -506,6 +521,11 @@ "sizeBytes": 1391, "sha256": "7a9f0562b680fef9312a59b5ac88d61e9c8abeee903a4a42ffca3b39d1a59b06" }, + { + "path": "dist/cli/semanticIntents/parsing.js", + "sizeBytes": 1021, + "sha256": "eb1493ecf0626b334a038603bd773a78310a20adbda39306738cc7bc4e03b9ab" + }, { "path": "dist/PollingTimeoutError.js", "sizeBytes": 172, @@ -516,6 +536,16 @@ "sizeBytes": 935, "sha256": "e01935073eab55214d9e37fa2d25e5615368efb8e9e2aedfa7a765e0d6e2bd84" }, + { + "path": "dist/cli/resultFiles.js", + "sizeBytes": 1839, + "sha256": "32e08477f67770ecbd65f63f1ecd05ce8eabc85caab495253c3319a6d4c8da33" + }, + { + "path": "dist/cli/resultUrls.js", + "sizeBytes": 1472, + "sha256": "745230607982ef264d4c59994748a0667b5f4ad82306864643cf9975a3c0209a" + }, { "path": "dist/robots.js", "sizeBytes": 8374, @@ -566,6 +596,11 @@ "sizeBytes": 43744, "sha256": "f7132f0384bd0f88787ca452f9145736ffbecf5a0975ee7bd1bd811aa4dde7f6" }, + { + "path": "dist/cli/stepsInput.js", + "sizeBytes": 947, + "sha256": "f491af528805b3a53f827f369202a05ff9a2f98d370be2d66d079679b7bcf6ee" + }, { "path": "dist/alphalib/types/robots/supabase-import.js", "sizeBytes": 4131, @@ -608,8 +643,8 @@ }, { "path": "dist/cli/commands/templates.js", - "sizeBytes": 17507, - "sha256": "632108b45ea9db0807be32316f73e71425134754a0657e83675287eefc777de8" + "sizeBytes": 16974, + "sha256": "f2ff5967c7f316469e53ad04337c03cc394837e4c457829bbc167755d30c1d4c" }, { "path": "dist/alphalib/types/robots/text-speak.js", @@ -738,8 +773,8 @@ }, { "path": "package.json", - "sizeBytes": 2734, - "sha256": "154923aac42eb65b220c74a778fddb5c74eef07d0024fbd325100f82993ce6b2" + "sizeBytes": 2855, + "sha256": "bf0b13fc2703400268108db330445563468f10088f9001eb884afd34a8e79522" }, { "path": "dist/alphalib/types/robots/_index.d.ts.map", @@ -764,12 +799,12 @@ { "path": "dist/alphalib/types/robots/ai-chat.d.ts.map", "sizeBytes": 3222, - "sha256": "349f27bcb874de663e0f67dc560546968ca50da02aab01eea7c8225400bca5f6" + "sha256": "0e23d607129767f18eb92b4f907a23f299234bceab87e651eab89209c284b42e" }, { "path": "dist/alphalib/types/robots/ai-chat.js.map", - "sizeBytes": 7650, - "sha256": "6c31c017e41a533499a0146d9ff4bd692a91d629ca2ee60e2c7d2ff88194f43b" + "sizeBytes": 7713, + "sha256": "9f80c0fbe2a754c9d659bb166393a8216e68d1007c91295724e1405ee6b8bf92" }, { "path": "dist/ApiError.d.ts.map", @@ -793,13 +828,13 @@ }, { "path": "dist/cli/commands/assemblies.d.ts.map", - "sizeBytes": 3889, - "sha256": "fdb9b7ad5f7d7ceae62c5bc690c823697b0316a460de39fa5243d5b89dcd6fb4" + "sizeBytes": 3983, + "sha256": "a4b8d3a3c9c3758a06c982b0654cffac60ce26a3fb3156466ba7128027d0db96" }, { "path": "dist/cli/commands/assemblies.js.map", - "sizeBytes": 46414, - "sha256": "7e3bc37a39d0d3a320a10a8a0dc65169cf10064e9e744700a1837bb22ccdb1f4" + "sizeBytes": 48725, + "sha256": "8d1c836fe4e8af2f0fff3e0d750ad763c56ef1c4cd2f6140a4f4a7e13560225f" }, { "path": "dist/alphalib/types/assembliesGet.d.ts.map", @@ -1053,13 +1088,13 @@ }, { "path": "dist/cli.d.ts.map", - "sizeBytes": 278, - "sha256": "5e6f1a916256a81fdc3e6678644c191a87bf1bbcb273e0e256a1e04533c045cd" + "sizeBytes": 293, + "sha256": "a9194c2c071b9b11546084324533c30a9188733778b0318be50f6a0f1917b6ae" }, { "path": "dist/cli.js.map", - "sizeBytes": 1335, - "sha256": "aa838fe53a894d7c2eca041e15963a3ddb50e3bf127911f472da237aec07ae56" + "sizeBytes": 1408, + "sha256": "00a1c4a99a63ed2b06d9529979d476ffacc8594bad5d891e6a2245fabd0fdfea" }, { "path": "dist/alphalib/types/robots/cloudfiles-import.d.ts.map", @@ -1231,6 +1266,16 @@ "sizeBytes": 1366, "sha256": "bf5b2ae88cc181f02afaf1ed026a327b8f1d3b4cec26ee2b87cb63d7dee4f17d" }, + { + "path": "dist/ensureUniqueCounter.d.ts.map", + "sizeBytes": 496, + "sha256": "f66924841143d5292339507d752fefc7d745c5979514f956c67ad70e490e1816" + }, + { + "path": "dist/ensureUniqueCounter.js.map", + "sizeBytes": 1452, + "sha256": "3e92164cbbc5518aad63241470f63338057435b549d6f55233301e8b5ae1b721" + }, { "path": "dist/alphalib/types/robots/file-compress.d.ts.map", "sizeBytes": 1263, @@ -1333,13 +1378,13 @@ }, { "path": "dist/cli/fileProcessingOptions.d.ts.map", - "sizeBytes": 911, - "sha256": "c2a4f82001dc780feba5894d66f72f1977d23e4ace574c0eda2c751946d11827" + "sizeBytes": 1573, + "sha256": "07e973b460ec4f6bc33c847caff81c037bad2c0218cfd45840fd560cf3347056" }, { "path": "dist/cli/fileProcessingOptions.js.map", - "sizeBytes": 1588, - "sha256": "3635f9b2407ba7bb4a82884b7c284aa651a54f3ba4b2b2df3cfb450ce179c76c" + "sizeBytes": 5543, + "sha256": "780b8f8b4a1368b11e99159994b39670e77fcfe1dbd2490600bb2e63f62b1587" }, { "path": "dist/alphalib/types/robots/ftp-import.d.ts.map", @@ -1362,14 +1407,14 @@ "sha256": "ce1bf48c1cc713ae843061cba3c3b119475baa5cb6b62ac4b575e50b297bcf71" }, { - "path": "dist/cli/commands/generated-intents.d.ts.map", - "sizeBytes": 9296, - "sha256": "3ce6b15ecd331d084554df1d694418252ca852fe7a3dba793a6c14a11db917f5" + "path": "dist/cli/generateIntentDocs.d.ts.map", + "sizeBytes": 137, + "sha256": "02ea53975f9b3a23e1a818db4c3b755229e06ecf2ae838ff8b5fe672b3127bb3" }, { - "path": "dist/cli/commands/generated-intents.js.map", - "sizeBytes": 39425, - "sha256": "bcbe46850689d5ac0ee546d7904623f41ddfe312f3bc4ad6ae71f39d69c23067" + "path": "dist/cli/generateIntentDocs.js.map", + "sizeBytes": 10178, + "sha256": "7871db734d48209977ffbff0f64810ba3d7860700cab6a48a1917fade2731575" }, { "path": "dist/alphalib/types/robots/google-import.d.ts.map", @@ -1501,6 +1546,16 @@ "sizeBytes": 9404, "sha256": "655db1c155f512b6b8b9fa21e8a6150ecab07e3b366e186694ef2b289f04b688" }, + { + "path": "dist/cli/semanticIntents/imageDescribe.d.ts.map", + "sizeBytes": 369, + "sha256": "f19041239dc6a3a59cba7469b24557feae411c6584483991b92f555dd2c7f76a" + }, + { + "path": "dist/cli/semanticIntents/imageDescribe.js.map", + "sizeBytes": 4469, + "sha256": "dd5584031d838472d9f2479e63bb437188ca674fa83b81ad948a576d1b52369b" + }, { "path": "dist/InconsistentResponseError.d.ts.map", "sizeBytes": 208, @@ -1514,42 +1569,62 @@ { "path": "dist/cli/commands/index.d.ts.map", "sizeBytes": 198, - "sha256": "6a459d827f048c87854b1570a2215cd69dc696ebe809a695a4d633e9dd4541ca" + "sha256": "b9e290d2d6c1c22f6396324b843032e88c2360d1d18fe45417519d1976df6a1e" + }, + { + "path": "dist/cli/semanticIntents/index.d.ts.map", + "sizeBytes": 844, + "sha256": "ba2f0a7b3f8b46fc34a5f42c10ba3aec67f9fd6ad46da5716a3b076977815a9d" }, { "path": "dist/cli/commands/index.js.map", "sizeBytes": 2088, - "sha256": "5e514ba662ee52294dc9b50a7744fa3c8d89f60d0f49eaa118d30e9b16bfcb39" + "sha256": "0c6068340d4cb461b7512bb30a77a6aad97a694fadab9c5a865d5cd52bd9f941" + }, + { + "path": "dist/cli/semanticIntents/index.js.map", + "sizeBytes": 564, + "sha256": "b2fb3dc5c6996eec5f528988648282e02a7b69c5aae556d354486e08f2197f9e" }, { "path": "dist/inputFiles.d.ts.map", - "sizeBytes": 1438, - "sha256": "ac8a1b3b69cfd346810bd841eb66bc8b61788a56ba75c1149dc7fba5757009b0" + "sizeBytes": 1772, + "sha256": "78a0f3fa2436ceef34d0b5c6a6a19f2d9dd66d255db7d93b552fc2e0575e496c" }, { "path": "dist/inputFiles.js.map", - "sizeBytes": 8595, - "sha256": "fa96090c58247759bef9b7767bd4b4f474bba332ee5a6edf0429e89e99a0c25c" + "sizeBytes": 14889, + "sha256": "376dc151d3746dbe074a72af20f6ded163ae237f3097fa125f1a1e4ba67c91f4" + }, + { + "path": "dist/cli/intentCommands.d.ts.map", + "sizeBytes": 546, + "sha256": "73d5f4fc9872f6119a1064f9767aa64da13524b4db8a1327d5bc6377a3e5f649" + }, + { + "path": "dist/cli/intentCommands.js.map", + "sizeBytes": 11291, + "sha256": "b4dfeb39c1007765bdbd79adea53614136aeb9f9b2a3e34273eae2db1db3b5ff" }, { "path": "dist/cli/intentCommandSpecs.d.ts.map", - "sizeBytes": 1195, - "sha256": "1621122696872464f8e01e51236beb7cccf3479149b3257141d16346f585622e" + "sizeBytes": 1276, + "sha256": "dd3fa43dbe3163e9b1523d46204f9462a87dcfb57359b8f08a895eb65b52b85b" }, { "path": "dist/cli/intentCommandSpecs.js.map", - "sizeBytes": 4862, - "sha256": "2e8c6e2ad7ca01caaad39a3404aa5bf0062569e37d30d27de5a473416127bbb4" + "sizeBytes": 5294, + "sha256": "3779e0d063e2751c4ea318fbd00a5c9dbf3641f59eb9695372bff405cf830e06" }, { "path": "dist/cli/intentFields.d.ts.map", - "sizeBytes": 492, - "sha256": "64be986a13e9b21e1c7bc047c01df4d06105eba0cb14da660791dd26a07b2090" + "sizeBytes": 1034, + "sha256": "631fe19761406fb6fda4a737bc9c5249f06fd67c98779b7c51273e67dd994f94" }, { "path": "dist/cli/intentFields.js.map", - "sizeBytes": 3606, - "sha256": "812807a35eb785d9415db2b134f766b25130f63a143955b07348ca52dbb608de" + "sizeBytes": 9386, + "sha256": "1777af4630ca6d645990bfc0003ea5f1dbce28dc722e5bdfc50da71ba7101e4e" }, { "path": "dist/cli/intentInputPolicy.d.ts.map", @@ -1561,35 +1636,15 @@ "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": 3272, - "sha256": "54e3e9404ce47f1450005a50b49a185469b5bb8f1afb1367c340d4b1b73f5951" + "sizeBytes": 4152, + "sha256": "a417392d5364cd67c7a943ad30434dde82bdde8ff306c8884c7070d850d0611e" }, { "path": "dist/cli/intentRuntime.js.map", - "sizeBytes": 9520, - "sha256": "804c1df6a3285d08b606b02b392271f4b64eded2dae29659eeef587d477c2ef2" - }, - { - "path": "dist/cli/intentSmokeCases.d.ts.map", - "sizeBytes": 369, - "sha256": "5fbbb3c25c53e55cc34ba0be87dcacd00373a6cc2ef774dedf58d1354998fbf3" - }, - { - "path": "dist/cli/intentSmokeCases.js.map", - "sizeBytes": 2362, - "sha256": "79eba061880bea4639cf758199a81f9ba4be20276b463f5d488d5a8054a0659f" + "sizeBytes": 14344, + "sha256": "36d1a03b18143667a9bf96edbd32372f734be9f3a5e275ad714f9e53a037ca68" }, { "path": "dist/lintAssemblyInput.d.ts.map", @@ -1611,6 +1666,16 @@ "sizeBytes": 1530, "sha256": "5aa4eee76ca12657a5c14abbe32c9b7d215bb641cde96da62695c290bee18e12" }, + { + "path": "dist/cli/semanticIntents/markdownPdf.d.ts.map", + "sizeBytes": 259, + "sha256": "f78aca0880c733033c6228b5f7af830ff8e28d80986ad8a2636cfc97fa50abfb" + }, + { + "path": "dist/cli/semanticIntents/markdownPdf.js.map", + "sizeBytes": 2115, + "sha256": "f5c08b48224d809c8859deb39f41c737dee71dc18930e3a94da6c0c9c3cf805f" + }, { "path": "dist/alphalib/mcache.d.ts.map", "sizeBytes": 968, @@ -1711,6 +1776,16 @@ "sizeBytes": 1478, "sha256": "e8f211d2956724af6936dfa9eb715a813925e43646136adf0e3da9a185594361" }, + { + "path": "dist/cli/semanticIntents/parsing.d.ts.map", + "sizeBytes": 545, + "sha256": "f6013135ed3fd450adab752591558b4e534abdda8d5feef4cefbf5f7b0d07ec3" + }, + { + "path": "dist/cli/semanticIntents/parsing.js.map", + "sizeBytes": 1153, + "sha256": "ca8ee3e14a26b695738bfe15c50dbfd0d397236ea0664e086dc69fec8dbc7bc2" + }, { "path": "dist/PollingTimeoutError.d.ts.map", "sizeBytes": 213, @@ -1731,6 +1806,26 @@ "sizeBytes": 854, "sha256": "c743fb4ea5217d34ff665926bd14ecbb259dec99c2de862abfe787ece58817a0" }, + { + "path": "dist/cli/resultFiles.d.ts.map", + "sizeBytes": 632, + "sha256": "03b62467c4747a1b902bf1becfa2cbf2adac2a7f116bc676311ce5317920c940" + }, + { + "path": "dist/cli/resultFiles.js.map", + "sizeBytes": 1969, + "sha256": "dd5c9fc2f547e875d4b51bbaf101b523e4006831e8512412b06368412b0975cf" + }, + { + "path": "dist/cli/resultUrls.d.ts.map", + "sizeBytes": 767, + "sha256": "86344c746e11849ae8063b6795d5a043ea57e6b482eeaefc2d166bf364277a8c" + }, + { + "path": "dist/cli/resultUrls.js.map", + "sizeBytes": 2000, + "sha256": "98b07c0bdf4a1402a7c3b8ed8968a4f18bba6cebff5adc1c844d9753386e59e3" + }, { "path": "dist/robots.d.ts.map", "sizeBytes": 1181, @@ -1831,6 +1926,16 @@ "sizeBytes": 41144, "sha256": "dc79c2623b6a27419f28023555ee9895ceeb0a387d0ac641f774090dcbc2c6fb" }, + { + "path": "dist/cli/stepsInput.d.ts.map", + "sizeBytes": 294, + "sha256": "b5b968d0ff47f7d6db5e08d6807c0dc37310c1bc02c04240222af0dd453ad860" + }, + { + "path": "dist/cli/stepsInput.js.map", + "sizeBytes": 1111, + "sha256": "8fe4317f1a79192083d3a01f9c7dbbed5b629b334fc1e6e20154b3c4b70e579d" + }, { "path": "dist/alphalib/types/robots/supabase-import.d.ts.map", "sizeBytes": 1036, @@ -1914,12 +2019,12 @@ { "path": "dist/cli/commands/templates.d.ts.map", "sizeBytes": 2386, - "sha256": "7561c84233a0db5dd6e75d2a49d434e6455e8964a96d997bfc712f089551b041" + "sha256": "96c5a9fd931ca11a188642835b6251621a9789e87dda53c25ea51fa075de7f96" }, { "path": "dist/cli/commands/templates.js.map", - "sizeBytes": 15860, - "sha256": "0c5b3ad523af04aec5a57deb83c7681c5f3ddd8d241664ce6f43c71e10769e04" + "sizeBytes": 15413, + "sha256": "ab60882bcfd691ec1dc5682b7d3beb1737cab4d1f942b37264ba6236d080cd60" }, { "path": "dist/alphalib/types/robots/text-speak.d.ts.map", @@ -2173,8 +2278,8 @@ }, { "path": "README.md", - "sizeBytes": 37376, - "sha256": "71e16691f95885bbd342ed8f02a8c447c968b6034fb8f16b35911ab7462abff9" + "sizeBytes": 81217, + "sha256": "4c190c948f0dbdc521fd2163675df8da755274846f73106721b352694c3e2914" }, { "path": "dist/alphalib/types/robots/_index.d.ts", @@ -2203,8 +2308,8 @@ }, { "path": "src/alphalib/types/robots/ai-chat.ts", - "sizeBytes": 10448, - "sha256": "4140d851971693e1aebdc675e71ffcdc98e7f9b31fcfa3c86f1ef8a6a5a8142b" + "sizeBytes": 10509, + "sha256": "afe48e03cd1a3eb5544d1d4dd8fc4fb782dff46fc38cc89a25db8eaee2ffdf3e" }, { "path": "dist/ApiError.d.ts", @@ -2228,13 +2333,13 @@ }, { "path": "dist/cli/commands/assemblies.d.ts", - "sizeBytes": 4488, - "sha256": "7dfbf42f5da3cb819883856d0c18166719149511509de1a2ad9eab8bf50e8d58" + "sizeBytes": 4598, + "sha256": "2bfe2853f48b9a9e828d909e4898b5ce39bee0ed8e80bf2bd188dac8fe5f424b" }, { "path": "src/cli/commands/assemblies.ts", - "sizeBytes": 52099, - "sha256": "4d41d313c6722cb601fa451c9d67a07c65f81659c6bd168205e061b023090bb1" + "sizeBytes": 55554, + "sha256": "cbafba38543a1cc993d9d29b8ac26a3fc221a04662fae3e358d5ee2f7b0b6a43" }, { "path": "dist/alphalib/types/assembliesGet.d.ts", @@ -2488,13 +2593,13 @@ }, { "path": "dist/cli.d.ts", - "sizeBytes": 256, - "sha256": "c0b85d46fb05f111ab4b71bf0adc491e71b78efd5b5344b74599e4126477979b" + "sizeBytes": 265, + "sha256": "84c403d5b19a2a87189fdf87a6a3b9d4f9dc23ff497f55ebacce6b72669adf8e" }, { "path": "src/cli.ts", - "sizeBytes": 1101, - "sha256": "9f7fa1f5565e87ffdf37abd416e6e77661d3cdba15513ae37fc9a5952a24abc0" + "sizeBytes": 1170, + "sha256": "757c3922b27c1d9c7fb2a496a66be1af298ed86b3e492fed6f43f7f08db1c8e0" }, { "path": "dist/alphalib/types/robots/cloudfiles-import.d.ts", @@ -2666,6 +2771,16 @@ "sizeBytes": 2785, "sha256": "40d7a8b6567fd80057781d7a24f093e9e1918ee45c3191085ee1bc256f9eeb44" }, + { + "path": "dist/ensureUniqueCounter.d.ts", + "sizeBytes": 350, + "sha256": "adc53cdb89d6f8560f7c632422fe7afa8b6d62e3cd33c7fa8645be7c3d4b193d" + }, + { + "path": "src/ensureUniqueCounter.ts", + "sizeBytes": 1646, + "sha256": "adce7911d379aa83abead36dc209293110fe475b691092ea7ec63d704c40f7df" + }, { "path": "dist/alphalib/types/robots/file-compress.d.ts", "sizeBytes": 20135, @@ -2768,13 +2883,13 @@ }, { "path": "dist/cli/fileProcessingOptions.d.ts", - "sizeBytes": 1095, - "sha256": "1faaca480253919fde59880952643428df7e387b4837040c77d18874255c0e81" + "sizeBytes": 2885, + "sha256": "805e3582fd29fb1d0e00343150f3ecb06aa53194d289ce1d5ae31632fb11d80b" }, { "path": "src/cli/fileProcessingOptions.ts", - "sizeBytes": 2331, - "sha256": "c9fbc2dc5bc2593f298f8ca47091643951bd22c6f08bd138d8ef8ade9c1f9357" + "sizeBytes": 8747, + "sha256": "742f0c0acfd7c45fb9f69448fcb805e21edf7998a97b81400cc5d35cb3223d86" }, { "path": "dist/alphalib/types/robots/ftp-import.d.ts", @@ -2797,14 +2912,14 @@ "sha256": "1bbaa2361cc3675a29178cbd0f4fcecaad1033032f154a6da36c5c677a9c9447" }, { - "path": "dist/cli/commands/generated-intents.d.ts", - "sizeBytes": 265589, - "sha256": "714397e265f3d3c87a085c6e60c1eae9b9ea5b1d9bff344e2172857a0f882d86" + "path": "dist/cli/generateIntentDocs.d.ts", + "sizeBytes": 59, + "sha256": "62a1df25d0d6a23b5c59ea877104bd2633759d655e526f1d8be6dde068dca46e" }, { - "path": "src/cli/commands/generated-intents.ts", - "sizeBytes": 83511, - "sha256": "ae240c3978168433d4dab3ebf24fc230eed59faa50c9288855421e3c04bd2ca8" + "path": "src/cli/generateIntentDocs.ts", + "sizeBytes": 12046, + "sha256": "4e85ded91f8ffa1ce8321d691b7b7941b916313266c3346dc22d23c8178bf59f" }, { "path": "dist/alphalib/types/robots/google-import.d.ts", @@ -2936,6 +3051,16 @@ "sizeBytes": 28301, "sha256": "23a89aaa7f7e7721eac3e7dafb1f82fdb5c1277dc30d85cf3e3364c478e45151" }, + { + "path": "dist/cli/semanticIntents/imageDescribe.d.ts", + "sizeBytes": 1971, + "sha256": "c9f9f5d960aa948ce6ee36c7b617cbd157082b581d5a280528e634bc812e170e" + }, + { + "path": "src/cli/semanticIntents/imageDescribe.ts", + "sizeBytes": 8077, + "sha256": "8651a889a6347ca3395d4559bed5c53930450e62c97a28849d7e72f3f3982054" + }, { "path": "dist/InconsistentResponseError.d.ts", "sizeBytes": 138, @@ -2951,40 +3076,60 @@ "sizeBytes": 110, "sha256": "8138bd76ab0a7ad7dc62b74d654fd7335de2fa86e1fb58f34788df74005ccc2d" }, + { + "path": "dist/cli/semanticIntents/index.d.ts", + "sizeBytes": 904, + "sha256": "de820295e77eb98c6aba606d3bcfe8fe81fac231eff1eda173696bbe4bf29243" + }, { "path": "src/cli/commands/index.ts", - "sizeBytes": 2200, - "sha256": "dcf03b6ac54bf0793a6be2cc945d8b8e3173d5de69366b19d78d960e4e1e8d2f" + "sizeBytes": 2195, + "sha256": "3a4b178bd5147f2621f7daa679081550ca410dc1c3e003d7bb9c55e525d05a7a" + }, + { + "path": "src/cli/semanticIntents/index.ts", + "sizeBytes": 1495, + "sha256": "63bd75a504db79877cd581c9404b8ad89f96242ece6edd02512f7203f8bf59d7" }, { "path": "dist/inputFiles.d.ts", - "sizeBytes": 1294, - "sha256": "dd490923c8af01790b1a7c72cd6578312a0af78ee035cc5fca55e24738d87fc1" + "sizeBytes": 1626, + "sha256": "d0bdfc4f2deca766146132c17e39c9457237d06db69ee13cffcd672c9ccc64c3" }, { "path": "src/inputFiles.ts", - "sizeBytes": 8411, - "sha256": "0df54cb83ac5c718f3d3f78ffb77a31d485e2ab5f0a9d91b4f64852e72d1a589" + "sizeBytes": 16515, + "sha256": "d9a6e9672639c307af6d2081dcbc2afa69789bf94b86a7336a63a0a5091c9f05" + }, + { + "path": "dist/cli/intentCommands.d.ts", + "sizeBytes": 716, + "sha256": "8b475be91c4bd98108fe0901ddcc9f6b6ef6f2efcd3056086e857e8fc99f0dc0" + }, + { + "path": "src/cli/intentCommands.ts", + "sizeBytes": 14926, + "sha256": "3c438cbadfdbbc4fc6b3c4bccc99dbdff443684d2a1fd4ccf85a9602560ff149" }, { "path": "dist/cli/intentCommandSpecs.d.ts", - "sizeBytes": 1439, - "sha256": "6cc613798ca129ddae21c32e9f41ff1100ec1062b7a692ad407598a047dc5c50" + "sizeBytes": 1548, + "sha256": "ab4516be3b7a30603d7f88c22d783f6ecc99c70f3a045655f96d14a03022cd44" }, { "path": "src/cli/intentCommandSpecs.ts", - "sizeBytes": 7289, - "sha256": "6361d5878bbc63b57abd1eadead9b9627dec0c75054b5c77efbb7f3ac61d75cd" + "sizeBytes": 7910, + "sha256": "ee7f5b712b51821894001b39327db289dc770fb1aaa24f21bd0069d6fe174b7b" }, { "path": "dist/cli/intentFields.d.ts", - "sizeBytes": 436, - "sha256": "c57fc802ff7528fbb9546869294aeeec3e066e06cadf6856c1bde04a3dc2fcb7" + "sizeBytes": 1018, + "sha256": "ed26c725a670f6c1b25c69863781aa1e423b9d4759fd4198dcf42b0fcbadd2bf" }, { "path": "src/cli/intentFields.ts", - "sizeBytes": 3285, - "sha256": "112fa2f6772eef50f2e7b528c8ba7eb349570e01e84011b30279ada2c34f3009" + "sizeBytes": 9355, + "sha256": "d104d57e54c3e17ad297449318a687c66d55089d9b2d8da279fbf20be5832a10" }, { "path": "dist/cli/intentInputPolicy.d.ts", @@ -2996,35 +3141,15 @@ "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": 4257, - "sha256": "5e205d60b47eaab7562af41bbbff7596345caf84b24debe4fae5d4e9323cbfe9" + "sizeBytes": 5649, + "sha256": "5b702a47758cf6f43fbd8c2f90f9cecc15c7c80ce0cc69abb4ed377d044c1ee0" }, { "path": "src/cli/intentRuntime.ts", - "sizeBytes": 13958, - "sha256": "be3abc271b0983b12e6c70b9c4943eda93205199d145f68a29c337b662700242" - }, - { - "path": "dist/cli/intentSmokeCases.d.ts", - "sizeBytes": 337, - "sha256": "d3a0809ad489635cb567005d0e29b024acfc4b480474d81e379c0e98b1b2ba48" - }, - { - "path": "src/cli/intentSmokeCases.ts", - "sizeBytes": 2939, - "sha256": "b6939c7182cf90b73da1fa279c1d44aee97086deb5280ade2e47c57e197fef44" + "sizeBytes": 20973, + "sha256": "d7e01809348fbf78f710eb0597b03e0e68507130c8b85bbd369649b96da3841d" }, { "path": "src/alphalib/typings/json-to-ast.d.ts", @@ -3051,6 +3176,16 @@ "sizeBytes": 2512, "sha256": "a6654b2dfc145fece2f4d2881a46e043187a5ada28b4eee52ea577666404b018" }, + { + "path": "dist/cli/semanticIntents/markdownPdf.d.ts", + "sizeBytes": 270, + "sha256": "17e49653f7ffff068fe48b06da554fcb39ed772ac1fad27602c849d810d5419c" + }, + { + "path": "src/cli/semanticIntents/markdownPdf.ts", + "sizeBytes": 3729, + "sha256": "f490142c883fcc314e03f677140f5d8b1476ca6581446c04c540b334a62e35a0" + }, { "path": "dist/alphalib/mcache.d.ts", "sizeBytes": 1881, @@ -3151,6 +3286,16 @@ "sizeBytes": 1505, "sha256": "43cc950855aa6e24d9a4105a3b9e2afcbeac8477797ff5d46bb9e9a6c8adacf1" }, + { + "path": "dist/cli/semanticIntents/parsing.d.ts", + "sizeBytes": 466, + "sha256": "85271f8ec364b5ac2f4c7409c2d1d4ed70caeebc0aa152cc6de9038b383d4b60" + }, + { + "path": "src/cli/semanticIntents/parsing.ts", + "sizeBytes": 1240, + "sha256": "b4e8d53cebe0e9f497e11bb03382af573d9e1a28745a13f919af40d5d77d7b39" + }, { "path": "dist/PollingTimeoutError.d.ts", "sizeBytes": 144, @@ -3171,6 +3316,26 @@ "sizeBytes": 1325, "sha256": "0591686d6c3787e0af4821649506d88034d3f302b021969dc91d612f7e9b3e8b" }, + { + "path": "dist/cli/resultFiles.d.ts", + "sizeBytes": 583, + "sha256": "da44f8a6f5f49b02e312ccd593a1077790fc023c0f1c733f25b4a23370209bd7" + }, + { + "path": "src/cli/resultFiles.ts", + "sizeBytes": 2294, + "sha256": "04d97b91032341c07788a07cfc3e31087bc852bed03e111169b9451fb5a6d641" + }, + { + "path": "dist/cli/resultUrls.d.ts", + "sizeBytes": 776, + "sha256": "b68efc4140cb3674d10c47bdc0e7ebd2fd2c1f2504661c6daad8829d8267deb3" + }, + { + "path": "src/cli/resultUrls.ts", + "sizeBytes": 1860, + "sha256": "230318a00385b69090feca3b57a286c7c29eaedc22c83db3b181ce3bffab8bf1" + }, { "path": "dist/robots.d.ts", "sizeBytes": 1050, @@ -3271,6 +3436,16 @@ "sizeBytes": 43731, "sha256": "c4f7bf2fcc33a453676b2bc96d6f6ff509be46b70fbf772de597cb8e3c40de21" }, + { + "path": "dist/cli/stepsInput.d.ts", + "sizeBytes": 262, + "sha256": "9ac695030494a59b06198beeed3d6ee1d7035703973f4ea0d48b04fcaf80acbb" + }, + { + "path": "src/cli/stepsInput.ts", + "sizeBytes": 1118, + "sha256": "3445d96c05ded3350346afd74f9a8b6082aebfd86a26b4be93058f376b900663" + }, { "path": "dist/alphalib/types/robots/supabase-import.d.ts", "sizeBytes": 12627, @@ -3358,8 +3533,8 @@ }, { "path": "src/cli/commands/templates.ts", - "sizeBytes": 17942, - "sha256": "c592539b044992c343abc91ef53fc9a2b5acbe0f8720ef525c424961df4e1975" + "sizeBytes": 17465, + "sha256": "ce9b61226de00d1e2b22bd31990b2c9ff506d2b1d97ff289032bc5f2ee3ca23c" }, { "path": "dist/alphalib/types/robots/text-speak.d.ts", diff --git a/docs/fingerprint/transloadit-baseline.package.json b/docs/fingerprint/transloadit-baseline.package.json index 99acf0ed..5fdcc4f0 100644 --- a/docs/fingerprint/transloadit-baseline.package.json +++ b/docs/fingerprint/transloadit-baseline.package.json @@ -36,6 +36,7 @@ "@aws-sdk/s3-request-presigner": "^3.891.0", "@transloadit/sev-logger": "^0.1.9", "@transloadit/utils": "^4.3.0", + "cacheable-lookup": "^7.0.0", "clipanion": "^4.0.0-rc.4", "debug": "^4.4.3", "dotenv": "^17.2.3", @@ -70,7 +71,8 @@ "src": "./src" }, "scripts": { - "check": "yarn lint:ts && yarn test:unit", + "check": "yarn sync:intent-docs && yarn lint:ts && yarn test:unit", + "sync:intent-docs": "node src/cli/generateIntentDocs.ts", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", diff --git a/knip.ts b/knip.ts index 85ee3de1..aa823c6b 100644 --- a/knip.ts +++ b/knip.ts @@ -75,6 +75,7 @@ const config: KnipConfig = { 'got', 'into-stream', 'is-stream', + 'cacheable-lookup', 'node-watch', 'p-map', 'p-queue', diff --git a/packages/node/package.json b/packages/node/package.json index d7d9427b..1e21f827 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -36,6 +36,7 @@ "@aws-sdk/s3-request-presigner": "^3.891.0", "@transloadit/sev-logger": "^0.1.9", "@transloadit/utils": "^4.3.0", + "cacheable-lookup": "^7.0.0", "clipanion": "^4.0.0-rc.4", "debug": "^4.4.3", "dotenv": "^17.2.3", diff --git a/packages/node/src/cli/commands/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 7edfed74..7e28e9cb 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -664,7 +664,7 @@ async function resolveResultDownloadTargets({ }) } - if (outputMode === 'directory' || outputPath == null) { + if (outputMode === 'directory' || outputPath == null || inPath == null) { return await buildDirectoryDownloadTargets({ allFiles, baseDir: resolveDirectoryBaseDir(), diff --git a/packages/node/src/cli/intentCommandSpecs.ts b/packages/node/src/cli/intentCommandSpecs.ts index afd6f9a5..db2cca33 100644 --- a/packages/node/src/cli/intentCommandSpecs.ts +++ b/packages/node/src/cli/intentCommandSpecs.ts @@ -142,10 +142,6 @@ export function getIntentPaths(definition: IntentDefinition): string[] { 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 diff --git a/packages/node/src/cli/intentRuntime.ts b/packages/node/src/cli/intentRuntime.ts index 123c2e28..1c07a22c 100644 --- a/packages/node/src/cli/intentRuntime.ts +++ b/packages/node/src/cli/intentRuntime.ts @@ -103,7 +103,7 @@ export function getInputBase64OptionDocumentation(): SharedCliOptionDocumentatio return inputBase64OptionDocumentation } -export function inputBase64Option(): string[] { +function inputBase64Option(): string[] { return Option.Array(inputBase64OptionDocumentation.flags, { description: inputBase64OptionDocumentation.description, }) as unknown as string[] @@ -250,7 +250,7 @@ export async function prepareIntentInputs({ } } -export function parseIntentStep({ +function parseIntentStep({ fields, fixedValues, rawValues, @@ -446,7 +446,7 @@ export function getIntentOptionDefinitions( return definition.execution.fields } -export function readIntentRawValues( +function readIntentRawValues( command: object, fieldDefinitions: readonly IntentOptionDefinition[], ): Record { @@ -461,7 +461,7 @@ export function readIntentRawValues( return rawValues } -export abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase { +abstract class GeneratedFileIntentCommandBase extends GeneratedIntentCommandBase { inputs = inputPathsOption('Provide an input path, directory, URL, or - for stdin') inputBase64 = inputBase64Option() diff --git a/packages/node/src/cli/resultFiles.ts b/packages/node/src/cli/resultFiles.ts index 73836a66..ee2aa970 100644 --- a/packages/node/src/cli/resultFiles.ts +++ b/packages/node/src/cli/resultFiles.ts @@ -53,7 +53,7 @@ function normalizeAssemblyResultUrl(file: AssemblyResultEntryLike): string | nul return null } -export function normalizeAssemblyResultFile( +function normalizeAssemblyResultFile( stepName: string, value: unknown, ): NormalizedAssemblyResultFile | null { diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index 62611d79..a1b2beab 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -25,7 +25,7 @@ const describeFieldDescriptions = { description: 'A richer description of the image suitable for CMS usage', } as const satisfies Record, string> -export const imageDescribeExecutionDefinition = { +const imageDescribeExecutionDefinition = { kind: 'dynamic-step', handler: 'image-describe', resultStepName: 'describe', @@ -59,7 +59,7 @@ export const imageDescribeExecutionDefinition = { ] as const satisfies readonly IntentOptionDefinition[], } satisfies IntentDynamicStepExecutionDefinition -export const imageDescribeCommandPresentation = { +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`.', @@ -212,9 +212,7 @@ function buildDescribeAiChatMessages({ } } -export function createImageDescribeStep( - rawValues: Record, -): Record { +function createImageDescribeStep(rawValues: Record): Record { const { fields, profile } = resolveImageDescribeRequest(rawValues) if (fields.length === 1 && fields[0] === 'labels') { return { diff --git a/packages/node/src/cli/semanticIntents/index.ts b/packages/node/src/cli/semanticIntents/index.ts index 897a0029..c7abab06 100644 --- a/packages/node/src/cli/semanticIntents/index.ts +++ b/packages/node/src/cli/semanticIntents/index.ts @@ -29,7 +29,7 @@ export interface SemanticIntentDescriptor { runnerKind: IntentRunnerKind } -export const semanticIntentDescriptors: Record = { +const semanticIntentDescriptors: Record = { 'image-describe': imageDescribeSemanticIntentDescriptor, 'markdown-pdf': { ...markdownPdfSemanticIntentDescriptor, diff --git a/packages/node/test/e2e/cli/assemblies-create.test.ts b/packages/node/test/e2e/cli/assemblies-create.test.ts index 13e4b1dd..a7dbd661 100644 --- a/packages/node/test/e2e/cli/assemblies-create.test.ts +++ b/packages/node/test/e2e/cli/assemblies-create.test.ts @@ -471,7 +471,7 @@ describeLive('assemblies', () => { ) it( - 'should close streams immediately in single-assembly mode', + 'should avoid opening filesystem streams during single-assembly collection', testCase(async (client) => { // Create multiple input files for single-assembly mode const fileCount = 5 @@ -493,18 +493,15 @@ describeLive('assemblies', () => { const outs = await fsp.readdir('out') expect(outs.length).to.be.greaterThan(0) - // Analyze debug output to verify streams were handled properly. - // The fixed code emits "STREAM CLOSED" when closing streams during collection. - // The unfixed code keeps all streams open until upload, risking fd exhaustion. + // Analyze debug output to verify filesystem inputs were collected as file paths. + // The current implementation only opens upload streams lazily for stdin inputs, + // so there should be no eager "STREAM CLOSED" churn during collection. const debugOutput = output.get(true) as OutputEntry[] const messages = debugOutput.map((e) => String(e.msg)) - // Check that streams were closed during collection (added by the fix) const streamClosedMessages = messages.filter((m) => m.startsWith('STREAM CLOSED')) - expect( - streamClosedMessages.length, - 'Expected "STREAM CLOSED" messages indicating proper fd management', - ).to.be.greaterThan(0) + expect(streamClosedMessages).to.have.lengthOf(0) + expect(messages).to.include(`Creating single assembly with ${fileCount} files`) }), ) }) diff --git a/packages/transloadit/package.json b/packages/transloadit/package.json index 99acf0ed..5fdcc4f0 100644 --- a/packages/transloadit/package.json +++ b/packages/transloadit/package.json @@ -36,6 +36,7 @@ "@aws-sdk/s3-request-presigner": "^3.891.0", "@transloadit/sev-logger": "^0.1.9", "@transloadit/utils": "^4.3.0", + "cacheable-lookup": "^7.0.0", "clipanion": "^4.0.0-rc.4", "debug": "^4.4.3", "dotenv": "^17.2.3", @@ -70,7 +71,8 @@ "src": "./src" }, "scripts": { - "check": "yarn lint:ts && yarn test:unit", + "check": "yarn sync:intent-docs && yarn lint:ts && yarn test:unit", + "sync:intent-docs": "node src/cli/generateIntentDocs.ts", "fix:js": "biome check --write .", "lint:ts": "yarn --cwd ../.. tsc:node", "fix:js:unsafe": "biome check --write . --unsafe", From 449377d99607af7c5aa781f4928e6bf55ade9363 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 19:29:56 +0200 Subject: [PATCH 68/69] chore: refresh lockfile for cacheable-lookup --- yarn.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yarn.lock b/yarn.lock index 9ebd10ac..2b8fa410 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2552,6 +2552,7 @@ __metadata: "@types/recursive-readdir": "npm:^2.2.4" "@types/temp": "npm:^0.9.4" badge-maker: "npm:^5.0.2" + cacheable-lookup: "npm:^7.0.0" clipanion: "npm:^4.0.0-rc.4" debug: "npm:^4.4.3" dotenv: "npm:^17.2.3" @@ -7664,6 +7665,7 @@ __metadata: "@types/minimist": "npm:^1.2.5" "@types/node": "npm:^24.10.3" "@types/recursive-readdir": "npm:^2.2.4" + cacheable-lookup: "npm:^7.0.0" clipanion: "npm:^4.0.0-rc.4" debug: "npm:^4.4.3" dotenv: "npm:^17.2.3" From a67de1f03e9f873dff80da9d122a86df7df5f9e8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 9 Apr 2026 19:34:26 +0200 Subject: [PATCH 69/69] chore(transloadit): track generated README --- packages/transloadit/README.md | 1736 ++++++++++++++++++++++++++++++++ 1 file changed, 1736 insertions(+) create mode 100644 packages/transloadit/README.md diff --git a/packages/transloadit/README.md b/packages/transloadit/README.md new file mode 100644 index 00000000..56f52324 --- /dev/null +++ b/packages/transloadit/README.md @@ -0,0 +1,1736 @@ +[![Build Status](https://github.com/transloadit/node-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/transloadit/node-sdk/actions/workflows/ci.yml) +[![Coverage](https://codecov.io/gh/transloadit/node-sdk/branch/main/graph/badge.svg)](https://codecov.io/gh/transloadit/node-sdk) + + + + + + Transloadit Logo + + + +This is the official **Node.js** SDK for [Transloadit](https://transloadit.com)'s file uploading and encoding service. + +## Intro + +[Transloadit](https://transloadit.com) is a service that helps you handle file +uploads, resize, crop and watermark your images, make GIFs, transcode your +videos, extract thumbnails, generate audio waveforms, [and so much more](https://transloadit.com/demos/). In +short, [Transloadit](https://transloadit.com) is the Swiss Army Knife for your +files. + +This is a **Node.js** SDK to make it easy to talk to the +[Transloadit](https://transloadit.com) REST API. + +## Requirements + +- [Node.js](https://nodejs.org/en/) version 20 or newer +- [A Transloadit account](https://transloadit.com/signup/) ([free signup](https://transloadit.com/pricing/)) +- [Your API credentials](https://transloadit.com/c/template-credentials) (`authKey`, `authSecret`) + +## Install + +Inside your project, type: + +```bash +yarn add @transloadit/node +``` + +or + +```bash +npm install --save @transloadit/node +``` + +The legacy npm package name `transloadit` is kept as an alias for backward compatibility, but +`@transloadit/node` is the canonical package name. + +## Command Line Interface (CLI) + +This package includes a full-featured CLI for interacting with Transloadit from your terminal. + +### Quick Start + +```bash +# Set your credentials +export TRANSLOADIT_KEY="YOUR_TRANSLOADIT_KEY" +export TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" + +# See all available commands +npx -y @transloadit/node --help +``` + +The CLI binary is still called `transloadit`, so command examples below may use +`npx transloadit ...`. + +### Minting Bearer Tokens (Hosted MCP) + +If you want to connect an agent to the Transloadit-hosted MCP endpoint, mint a short-lived bearer +token via `POST /token`: + +```bash +# Prints JSON to stdout (stderr may include npx/npm noise) +npx -y transloadit auth token --aud mcp +``` + +To reduce blast radius, you can request a narrower set of scopes. The server will only grant scopes +that your Auth Key already has (it applies an intersection), and will error if you request scopes +you are not allowed to use. + +```bash +# Request a narrower token (comma-separated scopes) +npx -y transloadit auth token --aud mcp --scope assemblies:write,templates:read +``` + +### Processing Media + +For common one-off tasks, prefer the intent-first commands: + +The full generated intent reference also lives in [`docs/intent-commands.md`](./docs/intent-commands.md). + + + +#### At a glance + +Intent commands are the fastest path to common one-off tasks from the CLI. +Use `--print-urls` when you want temporary result URLs without downloading locally. +All intent commands also support the global CLI flags `--json`, `--log-level`, `--endpoint`, and `--help`. + +| Command | What it does | Input | Output | +| --- | --- | --- | --- | +| `image generate` | Generate images from text prompts | none | file | +| `preview generate` | Generate a preview thumbnail | file, dir, URL, base64 | file | +| `image remove-background` | Remove the background from images | file, dir, URL, base64 | file | +| `image optimize` | Optimize images without quality loss | file, dir, URL, base64 | file | +| `image resize` | Convert, resize, or watermark images | file, dir, URL, base64 | file | +| `document convert` | Convert documents into different formats | file, dir, URL, base64 | file | +| `document optimize` | Reduce PDF file size | file, dir, URL, base64 | file | +| `document auto-rotate` | Auto-rotate documents to the correct orientation | file, dir, URL, base64 | file | +| `document thumbs` | Extract thumbnail images from documents | file, dir, URL, base64 | directory | +| `audio waveform` | Generate waveform images from audio | file, dir, URL, base64 | file | +| `text speak` | Speak text | file, dir, URL, base64 | file | +| `video thumbs` | Extract thumbnails from videos | file, dir, URL, base64 | directory | +| `video encode-hls` | Run builtin/encode-hls-video@latest | file, dir, URL, base64 | directory | +| `image describe` | Describe images as labels or publishable text fields | file, dir, URL, base64 | file | +| `markdown pdf` | Render Markdown files as PDFs | file, dir, URL, base64 | file | +| `markdown docx` | Render Markdown files as DOCX documents | file, dir, URL, base64 | file | +| `file compress` | Compress files | file, dir, URL, base64 | file | +| `file decompress` | Decompress archives | file, dir, URL, base64 | directory | + +> At least one of `--out` or `--print-urls` is required on every intent command. + +#### Shared flags + +These flags are available across many intent commands, so the per-command sections below focus on differences. + +**Shared file input & output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--input, -i` | `path \| dir \| url \| -` | varies | `input.file` | Provide an input path, directory, URL, or - for stdin | +| `--input-base64` | `base64 \| data URL` | no | `data:text/plain;base64,SGVsbG8=` | Provide base64-encoded input content directly | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path or directory | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Shared no-input output flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--out, -o` | `path` | yes* | `output.file` | Write the result to this path | +| `--print-urls` | `boolean` | no | `false` | Print temporary result URLs after completion | + +**Shared processing flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--recursive, -r` | `boolean` | no | `false` | Enumerate input directories recursively | +| `--delete-after-processing, -d` | `boolean` | no | `false` | Delete input files after they are processed | +| `--reprocess-stale` | `boolean` | no | `false` | Process inputs even if output is newer | + +**Shared watch flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--watch, -w` | `boolean` | no | `false` | Watch inputs for changes | +| `--concurrency, -c` | `number` | no | `5` | Maximum number of concurrent assemblies (default: 5) | + +**Shared bundling flags** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--single-assembly` | `boolean` | no | `false` | Pass all input files to a single assembly instead of one assembly per file | + +#### `image generate` + +Generate images from text prompts + +Runs `/image/generate` and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image generate [options] +``` + +**Quick facts** + +- Input: none +- Output: file +- Execution: no input +- Backend: `/image/generate` + +**Shared flags** + +- Uses the shared output flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--model` | `string` | no | `value` | The AI model to use for image generation. Defaults to google/nano-banana. | +| `--prompt` | `string` | yes | `"A red bicycle in a studio"` | The prompt describing the desired image content. | +| `--format` | `string` | no | `jpg` | Format of the generated image. | +| `--seed` | `number` | no | `1` | Seed for the random number generator. | +| `--aspect-ratio` | `string` | no | `value` | Aspect ratio of the generated image. | +| `--height` | `number` | no | `1` | Height of the generated image. | +| `--width` | `number` | no | `1` | Width of the generated image. | +| `--style` | `string` | no | `value` | Style of the generated image. | +| `--num-outputs` | `number` | no | `1` | Number of image variants to generate. | + +**Examples** + +```bash +transloadit image generate --prompt "A red bicycle in a studio" --out output.png +``` + +#### `preview generate` + +Generate a preview thumbnail + +Runs `/file/preview` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit preview generate --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/file/preview` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `jpg` | 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` | `number` | no | `1` | Width of the thumbnail, in pixels. | +| `--height` | `number` | no | `1` | Height of the thumbnail, in pixels. | +| `--resize-strategy` | `string` | no | `crop` | To achieve the desired dimensions of the preview thumbnail, the Robot might have to resize the generated image. | +| `--background` | `string` | no | `value` | The hexadecimal code of the color used to fill the background (only used for the pad resize strategy). | +| `--strategy` | `json` | no | `value` | Definition of the thumbnail generation process per file category. | +| `--artwork-outer-color` | `string` | no | `value` | The color used in the outer parts of the artwork's gradient. | +| `--artwork-center-color` | `string` | no | `value` | The color used in the center of the artwork's gradient. | +| `--waveform-center-color` | `string` | no | `value` | 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. | +| `--waveform-outer-color` | `string` | no | `value` | 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. | +| `--waveform-height` | `number` | no | `1` | 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… | +| `--waveform-width` | `number` | no | `1` | 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… | +| `--icon-style` | `string` | no | `square` | The style of the icon generated if the icon strategy is applied. | +| `--icon-text-color` | `string` | no | `value` | The color of the text used in the icon. The format is #rrggbb[aa]. Only used if the icon strategy is applied. | +| `--icon-text-font` | `string` | no | `value` | The font family of the text used in the icon. Only used if the icon strategy is applied. Here is a list of all supported fonts. | +| `--icon-text-content` | `string` | no | `extension` | 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)… | +| `--optimize` | `boolean` | no | `true` | Specifies whether the generated preview image should be optimized to reduce the image's file size while keeping their quaility. | +| `--optimize-priority` | `string` | no | `compression-ratio` | Specifies whether conversion speed or compression ratio is prioritized when optimizing images. | +| `--optimize-progressive` | `boolean` | no | `true` | Specifies whether images should be interlaced, which makes the result image load progressively in browsers. | +| `--clip-format` | `string` | no | `apng` | The animated image format for the generated video clip. Only used if the clip strategy for video files is applied. Please consult the MDN Web Docs for detailed information about… | +| `--clip-offset` | `number` | no | `1` | 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… | +| `--clip-duration` | `number` | no | `1` | 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… | +| `--clip-framerate` | `number` | no | `1` | 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… | +| `--clip-loop` | `boolean` | no | `true` | Specifies whether the generated animated image should loop forever (true) or stop after playing the animation once (false). | + +**Examples** + +```bash +transloadit preview generate --input input.file --out output.file +``` + +#### `image remove-background` + +Remove the background from images + +Runs `/image/bgremove` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image remove-background --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/bgremove` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--select` | `string` | no | `foreground` | Region to select and keep in the image. The other region is removed. | +| `--format` | `string` | no | `png` | Format of the generated image. | +| `--provider` | `string` | no | `aws` | Provider to use for removing the background. | +| `--model` | `string` | no | `value` | Provider-specific model to use for removing the background. Mostly intended for testing and evaluation. | + +**Examples** + +```bash +transloadit image remove-background --input input.png --out output.png +``` + +#### `image optimize` + +Optimize images without quality loss + +Runs `/image/optimize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image optimize --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/optimize` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--priority` | `string` | no | `compression-ratio` | Provides different algorithms for better or worse compression for your images, but that run slower or faster. | +| `--progressive` | `boolean` | no | `true` | Interlaces the image if set to true, which makes the result image load progressively in browsers. | +| `--preserve-meta-data` | `boolean` | no | `true` | Specifies if the image's metadata should be preserved during the optimization, or not. | +| `--fix-breaking-images` | `boolean` | no | `true` | If set to true this parameter tries to fix images that would otherwise make the underlying tool error out and thereby break your Assemblies . | + +**Examples** + +```bash +transloadit image optimize --input input.png --out output.png +``` + +#### `image resize` + +Convert, resize, or watermark images + +Runs `/image/resize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit image resize --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/image/resize` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `value` | The output format for the modified image. Some of the most important available formats are "jpg", "png", "gif", and "tiff". For a complete lists of all formats that we can write… | +| `--width` | `number` | no | `1` | Width of the result in pixels. If not specified, will default to the width of the original. | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image. | +| `--resize-strategy` | `string` | no | `crop` | See the list of available resize strategies. | +| `--zoom` | `boolean` | no | `true` | If this is set to false, smaller images will not be stretched to the desired width and height. | +| `--crop` | `auto` | no | `value` | Specify an object containing coordinates for the top left and bottom right corners of the rectangle to be cropped from the original image(s). | +| `--gravity` | `string` | no | `bottom` | The direction from which the image is to be cropped, when "resize_strategy" is set to "crop", but no crop coordinates are defined. | +| `--strip` | `boolean` | no | `true` | Strips all metadata from the image. This is useful to keep thumbnails as small as possible. | +| `--alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image. | +| `--preclip-alpha` | `string` | no | `Activate` | Gives control of the alpha/matte channel of an image before applying the clipping path via clip: true. | +| `--flatten` | `boolean` | no | `true` | Flattens all layers onto the specified background to achieve better results from transparent formats to non-transparent formats, as explained in the ImageMagick documentation. | +| `--correct-gamma` | `boolean` | no | `true` | Prevents gamma errors common in many image scaling algorithms. | +| `--quality` | `number` | no | `1` | Controls the image compression for JPG and PNG images. Please also take a look at 🤖/image/optimize. | +| `--adaptive-filtering` | `boolean` | no | `true` | 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` | `string` | no | `transparent` | Either the hexadecimal code or name of the color used to fill the background (used for the pad resize strategy). | +| `--frame` | `number` | no | `1` | Use this parameter when dealing with animated GIF files to specify which frame of the GIF is used for the operation. | +| `--colorspace` | `string` | no | `CMY` | Sets the image colorspace. For details about the available values, see the ImageMagick documentation. Please note that if you were using "RGB", we recommend using "sRGB" instead… | +| `--type` | `string` | no | `Bilevel` | Sets the image color type. For details about the available values, see the ImageMagick documentation. If you're using colorspace, ImageMagick might try to find the most efficient… | +| `--sepia` | `number` | no | `1` | Applies a sepia tone effect in percent. | +| `--rotation` | `auto` | no | `auto` | 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… | +| `--compress` | `string` | no | `BZip` | Specifies pixel compression for when the image is written. Compression is disabled by default. Please also take a look at 🤖/image/optimize. | +| `--blur` | `string` | no | `value` | Specifies gaussian blur, using a value with the form {radius}x{sigma}. | +| `--blur-regions` | `json` | no | `value` | Specifies an array of ellipse objects that should be blurred on the image. | +| `--brightness` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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` | `number` | no | `1` | 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… | +| `--watermark-url` | `string` | no | `value` | A URL indicating a PNG image to be overlaid above this image. | +| `--watermark-position` | `string[]` | no | `bottom` | 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". An… | +| `--watermark-x-offset` | `number` | no | `1` | 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. | +| `--watermark-y-offset` | `number` | no | `1` | 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. | +| `--watermark-size` | `string` | no | `value` | The size of the watermark, as a percentage. For 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… | +| `--watermark-resize-strategy` | `string` | no | `area` | Available values are "fit", "min_fit", "stretch" and "area". | +| `--watermark-opacity` | `number` | no | `1` | The opacity of the watermark, where 0.0 is fully transparent and 1.0 is fully opaque. | +| `--watermark-repeat-x` | `boolean` | no | `true` | When set to true, the watermark will be repeated horizontally across the entire width of the image. | +| `--watermark-repeat-y` | `boolean` | no | `true` | When set to true, the watermark will be repeated vertically across the entire height of the image. | +| `--text` | `json` | no | `value` | 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… | +| `--progressive` | `boolean` | no | `true` | Interlaces the image if set to true, which makes the image load progressively in browsers. | +| `--transparent` | `string` | no | `transparent` | Make this color transparent within the image. Example: "255,255,255". | +| `--trim-whitespace` | `boolean` | no | `true` | This determines if additional whitespace around the image should first be trimmed away. | +| `--clip` | `auto` | no | `value` | Apply the clipping path to other operations in the resize job, if one is present. | +| `--negate` | `boolean` | no | `true` | Replace each pixel with its complementary color, effectively negating the image. Especially useful when testing clipping. | +| `--density` | `string` | no | `value` | 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. | +| `--monochrome` | `boolean` | no | `true` | Transform the image to black and white. This is a shortcut for setting the colorspace to Gray and type to Bilevel. | +| `--shave` | `auto` | no | `value` | Shave pixels from the image edges. The value should be in the format width or widthxheight to specify the number of pixels to remove from each side. | + +**Examples** + +```bash +transloadit image resize --input input.png --out output.png +``` + +#### `document convert` + +Convert documents into different formats + +Runs `/document/convert` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document convert --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/convert` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | yes | `pdf` | The desired format for document conversion. | +| `--markdown-format` | `string` | no | `commonmark` | Markdown can be represented in several variants, so when using this Robot to transform Markdown into HTML please specify which revision is being used. | +| `--markdown-theme` | `string` | no | `bare` | This parameter overhauls your Markdown files styling based on several canned presets. | +| `--pdf-margin` | `string` | no | `value` | PDF Paper margins, separated by , and with units. We support the following unit values: px, in, cm, mm. Currently this parameter is only supported when converting from html. | +| `--pdf-print-background` | `boolean` | no | `true` | Print PDF background graphics. Currently this parameter is only supported when converting from html. | +| `--pdf-format` | `string` | no | `A0` | PDF paper format. Currently this parameter is only supported when converting from html. | +| `--pdf-display-header-footer` | `boolean` | no | `true` | Display PDF header and footer. Currently this parameter is only supported when converting from html. | +| `--pdf-header-template` | `string` | no | `value` | HTML template for the PDF print header. Should be valid HTML markup with following classes used to inject printing values into them: - date formatted print date - title document… | +| `--pdf-footer-template` | `string` | no | `value` | HTML template for the PDF print footer. Should use the same format as the pdf_header_template. Currently this parameter is only supported when converting from html, and requires… | + +**Examples** + +```bash +transloadit document convert --input input.pdf --format pdf --out output.pdf +``` + +#### `document optimize` + +Reduce PDF file size + +Runs `/document/optimize` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document optimize --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/optimize` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--preset` | `string` | no | `screen` | The quality preset to use for optimization. Each preset provides a different balance between file size and quality: - screen - Lowest quality, smallest file size. Best for screen… | +| `--image-dpi` | `number` | no | `1` | Target DPI (dots per inch) for embedded images. When specified, this overrides the DPI setting from the preset. Higher DPI values result in better image quality but larger file… | +| `--compress-fonts` | `boolean` | no | `true` | Whether to compress embedded fonts. When enabled, fonts are compressed to reduce file size. | +| `--subset-fonts` | `boolean` | no | `true` | Whether to subset embedded fonts, keeping only the glyphs that are actually used in the document. | +| `--remove-metadata` | `boolean` | no | `true` | 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` | `boolean` | no | `true` | Whether to linearize (optimize for Fast Web View) the output PDF. | +| `--compatibility` | `string` | no | `1.4` | 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… | + +**Examples** + +```bash +transloadit document optimize --input input.pdf --out output.pdf +``` + +#### `document auto-rotate` + +Auto-rotate documents to the correct orientation + +Runs `/document/autorotate` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit document auto-rotate --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/autorotate` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Examples** + +```bash +transloadit document auto-rotate --input input.pdf --out output.pdf +``` + +#### `document thumbs` + +Extract thumbnail images from documents + +Runs `/document/thumbs` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit document thumbs --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/document/thumbs` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--page` | `number` | no | `1` | 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` | `string` | no | `jpg` | The format of the extracted image(s). If you specify the value "gif", then an animated gif cycling through all pages is created. Please check out this demo to learn more about… | +| `--delay` | `number` | no | `1` | 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. | +| `--width` | `number` | no | `1` | Width of the new image, in pixels. If not specified, will default to the width of the input image | +| `--height` | `number` | no | `1` | Height of the new image, in pixels. If not specified, will default to the height of the input image | +| `--resize-strategy` | `string` | no | `crop` | One of the available resize strategies. | +| `--background` | `string` | no | `value` | Either the hexadecimal code or name of the color used to fill the background (only used for the pad resize strategy). | +| `--alpha` | `string` | no | `Remove` | Change how the alpha channel of the resulting image should work. | +| `--density` | `string` | no | `value` | 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. | +| `--antialiasing` | `boolean` | no | `true` | Controls whether or not antialiasing is used to remove jagged edges from text or images in a document. | +| `--colorspace` | `string` | no | `CMY` | Sets the image colorspace. For details about the available values, see the ImageMagick documentation. Please note that if you were using "RGB", we recommend using "sRGB".… | +| `--trim-whitespace` | `boolean` | no | `true` | This determines if additional whitespace around the PDF should first be trimmed away before it is converted to an image. | +| `--pdf-use-cropbox` | `boolean` | no | `true` | 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… | +| `--turbo` | `boolean` | no | `true` | If you set this to false, the robot will not emit files as they become available. | + +**Examples** + +```bash +transloadit document thumbs --input input.pdf --out output/ +``` + +#### `audio waveform` + +Generate waveform images from audio + +Runs `/audio/waveform` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit audio waveform --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/audio/waveform` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--ffmpeg` | `json` | no | `value` | 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… | +| `--format` | `string` | no | `image` | 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` | `number` | no | `1` | The width of the resulting image if the format "image" was selected. | +| `--height` | `number` | no | `1` | The height of the resulting image if the format "image" was selected. | +| `--antialiasing` | `auto` | no | `0` | 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. | +| `--background-color` | `string` | no | `value` | The background color of the resulting image in the "rrggbbaa" format (red, green, blue, alpha), if the format "image" was selected. | +| `--center-color` | `string` | no | `value` | The color used in the center of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--outer-color` | `string` | no | `value` | The color used in the outer parts of the gradient. The format is "rrggbbaa" (red, green, blue, alpha). | +| `--style` | `string` | no | `v0` | Waveform style version. - "v0": Legacy waveform generation (default). - "v1": Advanced waveform generation with additional parameters. For backwards compatibility, numeric values… | +| `--split-channels` | `boolean` | no | `true` | Available when style is "v1". If set to true, outputs multi-channel waveform data or image files, one per channel. | +| `--zoom` | `number` | no | `1` | Available when style is "v1". Zoom level in samples per pixel. This parameter cannot be used together with pixels_per_second. | +| `--pixels-per-second` | `number` | no | `1` | Available when style is "v1". Zoom level in pixels per second. This parameter cannot be used together with zoom. | +| `--bits` | `number` | no | `8` | Available when style is "v1". Bit depth for waveform data. Can be 8 or 16. | +| `--start` | `number` | no | `1` | Available when style is "v1". Start time in seconds. | +| `--end` | `number` | no | `1` | Available when style is "v1". End time in seconds (0 means end of audio). | +| `--colors` | `string` | no | `audition` | Available when style is "v1". Color scheme to use. Can be "audition" or "audacity". | +| `--border-color` | `string` | no | `value` | Available when style is "v1". Border color in "rrggbbaa" format. | +| `--waveform-style` | `string` | no | `normal` | Available when style is "v1". Waveform style. Can be "normal" or "bars". | +| `--bar-width` | `number` | no | `1` | Available when style is "v1". Width of bars in pixels when waveform_style is "bars". | +| `--bar-gap` | `number` | no | `1` | Available when style is "v1". Gap between bars in pixels when waveform_style is "bars". | +| `--bar-style` | `string` | no | `square` | Available when style is "v1". Bar style when waveform_style is "bars". | +| `--axis-label-color` | `string` | no | `value` | Available when style is "v1". Color for axis labels in "rrggbbaa" format. | +| `--no-axis-labels` | `boolean` | no | `true` | Available when style is "v1". If set to true, renders waveform image without axis labels. | +| `--with-axis-labels` | `boolean` | no | `true` | Available when style is "v1". If set to true, renders waveform image with axis labels. | +| `--amplitude-scale` | `number` | no | `1` | Available when style is "v1". Amplitude scale factor. | +| `--compression` | `number` | no | `1` | Available when style is "v1". PNG compression level: 0 (none) to 9 (best), or -1 (default). Only applicable when format is "image". | + +**Examples** + +```bash +transloadit audio waveform --input input.mp3 --out output.png +``` + +#### `text speak` + +Speak text + +Runs `/text/speak` on each input file and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit text speak --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/text/speak` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--prompt` | `string` | no | `"A red bicycle in a studio"` | Which text to speak. You can also set this to null and supply an input text file. | +| `--provider` | `string` | yes | `aws` | Which AI provider to leverage. Transloadit outsources this task and abstracts the interface so you can expect the same data structures, but different latencies and information… | +| `--target-language` | `string` | no | `en-US` | The written language of the document. This will also be the language of the spoken text. The language should be specified in the BCP-47 format, such as "en-GB", "de-DE" or… | +| `--voice` | `string` | no | `female-1` | The gender to be used for voice synthesis. Please consult the list of supported languages and voices. | +| `--ssml` | `boolean` | no | `true` | Supply Speech Synthesis Markup Language instead of raw text, in order to gain more control over how your text is voiced, including rests and pronounciations. | + +**Examples** + +```bash +transloadit text speak --input input.pdf --provider aws --out output.mp3 +``` + +#### `video thumbs` + +Extract thumbnails from videos + +Runs `/video/thumbs` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit video thumbs --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/video/thumbs` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--ffmpeg` | `json` | no | `value` | 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… | +| `--count` | `number` | no | `1` | 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… | +| `--offsets` | `auto` | no | `value` | An array of offsets representing seconds of the file duration, such as [ 2, 45, 120 ]. | +| `--format` | `string` | no | `jpg` | 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… | +| `--width` | `number` | no | `1` | The width of the thumbnail, in pixels. Defaults to the original width of the video. | +| `--height` | `number` | no | `1` | The height of the thumbnail, in pixels. Defaults to the original height of the video. | +| `--resize-strategy` | `string` | no | `crop` | One of the available resize strategies. | +| `--background` | `string` | no | `value` | 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` | `number` | no | `0` | Forces the video to be rotated by the specified degree integer. | +| `--input-codec` | `string` | no | `value` | Specifies the input codec to use when decoding the video. This is useful for videos with special codecs that require specific decoders. | + +**Examples** + +```bash +transloadit video thumbs --input input.mp4 --out output/ +``` + +#### `video encode-hls` + +Run builtin/encode-hls-video@latest + +Runs the `builtin/encode-hls-video@latest` template and writes the outputs to `--out`. + +**Usage** + +```bash +npx transloadit video encode-hls --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `builtin/encode-hls-video@latest` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Examples** + +```bash +transloadit video encode-hls --input input.mp4 --out output/ +``` + +#### `image describe` + +Describe images as labels or publishable text fields + +Generates image labels through `/image/describe`, or structured altText/title/caption/description through `/ai/chat`, then writes the JSON result to `--out`. + +**Usage** + +```bash +npx transloadit image describe --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `image-describe` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | +| `--for` | `string` | no | — | Use a named output profile, currently: wordpress | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514) | + +**Examples** + +```bash +# 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 +``` + +#### `markdown pdf` + +Render Markdown files as PDFs + +Runs `/document/convert` with `format: pdf`, letting the backend render Markdown and preserve features such as internal heading links in the generated PDF. + +**Usage** + +```bash +npx transloadit markdown pdf --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `markdown-pdf` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | +| `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | + +**Examples** + +```bash +# Render a Markdown file as a PDF file +transloadit markdown pdf --input README.md --out README.pdf +# Print a temporary result URL without downloading locally +transloadit markdown pdf --input README.md --print-urls +``` + +#### `markdown docx` + +Render Markdown files as DOCX documents + +Runs `/document/convert` with `format: docx`, letting the backend render Markdown and convert it into a Word document. + +**Usage** + +```bash +npx transloadit markdown docx --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: per-file; supports `--watch` +- Backend: semantic alias `markdown-docx` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--markdown-format` | `string` | no | — | Markdown variant to parse, either commonmark or gfm | +| `--markdown-theme` | `string` | no | — | Markdown theme to render, either github or bare | + +**Examples** + +```bash +# Render a Markdown file as a DOCX file +transloadit markdown docx --input README.md --out README.docx +# Print a temporary result URL without downloading locally +transloadit markdown docx --input README.md --print-urls +``` + +#### `file compress` + +Compress files + +Runs `/file/compress` for the provided inputs and writes the result to `--out`. + +**Usage** + +```bash +npx transloadit file compress --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: file +- Execution: single assembly +- Backend: `/file/compress` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags listed above. + +**Command options** + +| Flag | Type | Required | Example | Description | +| --- | --- | --- | --- | --- | +| `--format` | `string` | no | `zip` | The format of the archive to be created. Supported values are "tar" and "zip". Note that "tar" without setting gzip to true results in an archive that's not compressed in any way. | +| `--gzip` | `boolean` | no | `true` | Determines if the result archive should also be gzipped. Gzip compression is only applied if you use the "tar" format. | +| `--password` | `string` | no | `value` | This allows you to encrypt all archive contents with a password and thereby protect it against unauthorized use. | +| `--compression-level` | `number` | no | `1` | 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… | +| `--file-layout` | `string` | no | `advanced` | 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… | +| `--archive-name` | `string` | no | `value` | The name of the archive file to be created (without the file extension). | + +**Examples** + +```bash +transloadit file compress --input input.file --out output.file +``` + +#### `file decompress` + +Decompress archives + +Runs `/file/decompress` on each input file and writes the results to `--out`. + +**Usage** + +```bash +npx transloadit file decompress --input [options] +``` + +**Quick facts** + +- Input: file, dir, URL, base64 +- Output: directory +- Execution: per-file; supports `--single-assembly` and `--watch` +- Backend: `/file/decompress` + +**Shared flags** + +- Uses the shared file input and output flags listed above. +- Also supports the shared base processing flags, watch flags, bundling flags listed above. + +**Examples** + +```bash +transloadit file decompress --input input.file --out output/ +``` + + + +For full control, create Assemblies directly using Assembly Instructions (steps) or Templates: + +```bash +# Process a file using a steps file +npx transloadit assemblies create --steps steps.json --input image.jpg --output result.jpg + +# Process using a Template +npx transloadit assemblies create --template YOUR_TEMPLATE_ID --input image.jpg --output result.jpg + +# Process with custom fields +npx transloadit assemblies create --template YOUR_TEMPLATE_ID --field size=100 --input image.jpg --output thumb.jpg + +# Process a directory of files +npx transloadit assemblies create --template YOUR_TEMPLATE_ID --input images/ --output thumbs/ + +# Process recursively with file watching +npx transloadit assemblies create --template YOUR_TEMPLATE_ID --input images/ --output thumbs/ --recursive --watch + +# Process multiple files in a single assembly +npx transloadit assemblies create --template YOUR_TEMPLATE_ID --input file1.jpg --input file2.jpg --output results/ --single-assembly + +# Limit concurrent processing (default: 5) +npx transloadit assemblies create --template YOUR_TEMPLATE_ID --input images/ --output thumbs/ --concurrency 2 +``` + +### Managing Assemblies + +```bash +# List recent assemblies +npx transloadit assemblies list + +# List assemblies with filters +npx transloadit assemblies list --after 2024-01-01 --before 2024-12-31 + +# Get assembly status +npx transloadit assemblies get ASSEMBLY_ID + +# Cancel an assembly +npx transloadit assemblies delete ASSEMBLY_ID + +# Replay an assembly (re-run with original instructions) +npx transloadit assemblies replay ASSEMBLY_ID + +# Replay with different steps +npx transloadit assemblies replay --steps new-steps.json ASSEMBLY_ID + +# Replay using latest template version +npx transloadit assemblies replay --reparse-template ASSEMBLY_ID +``` + +### Linting Assembly Instructions + +Lint Assembly Instructions locally using the same linter as the API. + +```bash +# From a JSON file (full instructions or steps-only) +npx transloadit assemblies lint --steps steps.json + +# From stdin +cat steps.json | npx transloadit assemblies lint + +# Merge template content before linting +npx transloadit assemblies lint --template TEMPLATE_ID --steps steps.json + +# Treat warnings as fatal; apply fixes (overwrites files / stdout for stdin) +npx transloadit assemblies lint --fatal warning --fix --steps steps.json +``` + +When both `--template` and steps input are provided, Transloadit merges the template content with +the provided steps before linting, matching the API's runtime behavior. If the template sets +`allow_steps_override=false`, providing steps will fail with `TEMPLATE_DENIES_STEPS_OVERRIDE`. + +## SDK Helpers + +### prepareInputFiles + +`prepareInputFiles()` converts mixed file inputs into `files`, `uploads`, and optional +`/http/import` steps so you can pass them directly into `createAssembly()` or +`resumeAssemblyUploads()`. + +```ts +import { prepareInputFiles } from '@transloadit/node' + +const prepared = await prepareInputFiles({ + inputFiles: [ + { kind: 'path', field: 'video', path: '/tmp/video.mp4' }, + { kind: 'base64', field: 'logo', filename: 'logo.png', base64: '...' }, + { kind: 'url', field: 'remote', url: 'https://example.com/file.jpg' }, + ], + params: { + steps: { + ':original': { robot: '/upload/handle' }, + encode: { robot: '/video/encode', use: ':original' }, + }, + }, + base64Strategy: 'tempfile', + urlStrategy: 'import-if-present', + maxBase64Bytes: 512_000, + allowPrivateUrls: true, +}) + +await client.createAssembly({ + params: prepared.params, + files: prepared.files, + uploads: prepared.uploads, +}) +``` + +Options: + +- `inputFiles` – Array of `{ kind, field, ... }` entries for `path`, `base64`, or `url` inputs. +- `params` – Assembly instructions; steps will be extended when URL imports are injected. +- `fields` – Extra form fields to merge into `params.fields`. +- `base64Strategy` – `'buffer'` (default) or `'tempfile'` for base64 inputs. +- `urlStrategy` – `'import'`, `'download'`, or `'import-if-present'` (default `'import'`). +- `maxBase64Bytes` – Optional size cap (decoded bytes). Overages throw before decoding. +- `allowPrivateUrls` – Allow downloading private/loopback URLs when using `urlStrategy: 'download'` + (default `true`). Hosted deployments should disable this. +- `tempDir` – Optional temp directory base when `base64Strategy: 'tempfile'`. + +### Managing Templates + +```bash +# List all templates +npx transloadit templates list + +# Get template content +npx transloadit templates get TEMPLATE_ID + +# Create a template from a JSON file +npx transloadit templates create my-template template.json + +# Modify a template +npx transloadit templates modify TEMPLATE_ID template.json + +# Rename a template +npx transloadit templates modify TEMPLATE_ID --name new-name + +# Delete a template +npx transloadit templates delete TEMPLATE_ID + +# Sync local template files with Transloadit (bidirectional) +npx transloadit templates sync templates/*.json +npx transloadit templates sync --recursive templates/ +``` + +### Billing + +```bash +# Get bill for a month +npx transloadit bills get 2024-01 + +# Get detailed bill as JSON +npx transloadit bills get 2024-01 --json +``` + +### Assembly Notifications + +```bash +# Replay a notification +npx transloadit assembly-notifications replay ASSEMBLY_ID + +# Replay to a different URL +npx transloadit assembly-notifications replay --notify-url https://example.com/hook ASSEMBLY_ID +``` + +### Signature Generation + +```bash +# Generate a signature for assembly params +echo '{"steps":{}}' | npx transloadit auth signature + +# Generate with specific algorithm +echo '{"steps":{}}' | npx transloadit auth signature --algorithm sha256 + +# Generate a signed Smart CDN URL +echo '{"workspace":"my-workspace","template":"my-template","input":"image.jpg"}' | npx transloadit auth smart-cdn +``` + +### CLI Options + +All commands support these common options: + +- `--json, -j` - Output results as JSON (useful for scripting) +- `--log-level, -l` - Set log verbosity level by name or number (default: notice) +- `--endpoint` - Custom API endpoint URL (or set `TRANSLOADIT_ENDPOINT` env var) +- `--help, -h` - Show help for a command + +The `assemblies create` command additionally supports: + +- `--single-assembly` - Pass all input files to a single assembly instead of one assembly per file + +#### Log Levels + +The CLI uses [syslog severity levels](https://en.wikipedia.org/wiki/Syslog#Severity_level). Lower = more severe, higher = more verbose: + +| Level | Value | Description | +| -------- | ----- | ------------------------------------- | +| `err` | 3 | Error conditions | +| `warn` | 4 | Warning conditions | +| `notice` | 5 | Normal but significant **(default)** | +| `info` | 6 | Informational messages | +| `debug` | 7 | Debug-level messages | +| `trace` | 8 | Most verbose/detailed | + +You can use either the level name or its numeric value: + +```bash +# Show only errors and warnings +npx transloadit assemblies list -l warn +npx transloadit assemblies list -l 4 + +# Show debug output +npx transloadit assemblies list -l debug +npx transloadit assemblies list -l 7 +``` + +## SDK Usage + +The following code will upload an image and resize it to a thumbnail: + +```javascript +import { Transloadit } from '@transloadit/node' + +const transloadit = new Transloadit({ + authKey: 'YOUR_TRANSLOADIT_KEY', + authSecret: 'YOUR_TRANSLOADIT_SECRET', +}) + +try { + const options = { + files: { + file1: '/PATH/TO/FILE.jpg', + }, + params: { + steps: { + // You can have many Steps. In this case we will just resize any inputs (:original) + resize: { + use: ':original', + robot: '/image/resize', + result: true, + width: 75, + height: 75, + }, + }, + // OR if you already created a template, you can use it instead of "steps": + // template_id: 'YOUR_TEMPLATE_ID', + }, + waitForCompletion: true, // Wait for the Assembly (job) to finish executing before returning + } + + const status = await transloadit.createAssembly(options) + + if (status.results.resize) { + console.log('✅ Success - Your resized image:', status.results.resize[0].ssl_url) + } else { + console.log("❌ The Assembly didn't produce any output. Make sure you used a valid image file") + } +} catch (err) { + console.error('❌ Unable to process Assembly.', err) + if (err instanceof ApiError && err.assemblyId) { + console.error(`💡 More info: https://transloadit.com/assemblies/${err.assemblyId}`) + } +} +``` + +You can find [details about your executed Assemblies here](https://transloadit.com/assemblies). + +### Resuming interrupted uploads + +If an upload was interrupted, you can resume it by providing the original `assemblyUrl` and the +same input mapping. Resume relies on matching `fieldname`, `filename`, and `size`, so keep input +names stable and pass the same files. Only path-based inputs resume; Buffer/string/stream uploads +start a new tus upload automatically. + +You can pass the same upload and progress options as `createAssembly` (such as `chunkSize`, +`uploadConcurrency`, `uploadBehavior`, `waitForCompletion`, `timeout`, `onUploadProgress`, and +`onAssemblyProgress`). +When `waitForCompletion` is `true`, the SDK will poll and resolve once the Assembly is finished. + +```javascript +const status = await transloadit.resumeAssemblyUploads({ + assemblyUrl: 'https://api2.transloadit.com/assemblies/ASSEMBLY_ID', + files: { + file1: '/PATH/TO/FILE.jpg', + file2: '/PATH/TO/FILE2.jpg', + }, + uploadConcurrency: 2, +}) +``` + +## Examples + +- [Upload and resize image](https://github.com/transloadit/node-sdk/blob/main/examples/resize_an_image.ts) +- [Upload image and convert to WebP](https://github.com/transloadit/node-sdk/blob/main/examples/convert_to_webp.ts) +- [Rasterize SVG to PNG](https://github.com/transloadit/node-sdk/blob/main/examples/rasterize_svg_to_png.ts) +- [Crop a face out of an image and download the result](https://github.com/transloadit/node-sdk/blob/main/examples/face_detect_download.ts) +- [Retry example](https://github.com/transloadit/node-sdk/blob/main/examples/retry.ts) +- [Calculate total costs (GB usage)](https://github.com/transloadit/node-sdk/blob/main/examples/fetch_costs_of_all_assemblies_in_timeframe.ts) +- [Templates CRUD](https://github.com/transloadit/node-sdk/blob/main/examples/template_api.ts) +- [Template Credentials CRUD](https://github.com/transloadit/node-sdk/blob/main/examples/credentials.ts) + +For more fully working examples take a look at [`examples/`](https://github.com/transloadit/node-sdk/blob/main/examples/). + +For more example use cases and information about the available robots and their parameters, check out the [Transloadit website](https://transloadit.com/). + +## API + +These are the public methods on the `Transloadit` object and their descriptions. The methods are based on the [Transloadit API](https://transloadit.com/docs/api/). + +Table of contents: + +- [Main](#main) +- [Assemblies](#assemblies) +- [Assembly notifications](#assembly-notifications) +- [Templates](#templates) +- [Template Credentials](#template-credentials) +- [Errors](#errors) +- [Rate limiting & auto retry](#rate-limiting--auto-retry) + +### Main + +#### constructor(options) + +Returns a new instance of the client. + +The `options` object can contain the following keys: + +- `authKey` **(required)** - see [requirements](#requirements) +- `authSecret` **(required)** - see [requirements](#requirements) +- `endpoint` (default `'https://api2.transloadit.com'`) +- `maxRetries` (default `5`) - see [Rate limiting & auto retry](#rate-limiting--auto-retry) +- `gotRetry` (default `0`) - see [Rate limiting & auto retry](#rate-limiting--auto-retry) +- `timeout` (default `60000`: 1 minute) - the timeout (in milliseconds) for all requests (except `createAssembly`) +- `validateResponses` (default `false`) + +### Assemblies + +#### async createAssembly(options) + +Creates a new Assembly on Transloadit and optionally upload the specified `files` and `uploads`. + +You can provide the following keys inside the `options` object: + +- `params` **(required)** - An object containing keys defining the Assembly's behavior with the following keys: (See also [API doc](https://transloadit.com/docs/api/assemblies-post/) and [examples](#examples)) + - `steps` - Assembly instructions - See [Transloadit docs](https://transloadit.com/docs/topics/assembly-instructions/) and [demos](https://transloadit.com/demos/) for inspiration. + - `template_id` - The ID of the Template that contains your Assembly Instructions. **One of either `steps` or `template_id` is required.** If you specify both, then [any Steps will overrule the template](https://transloadit.com/docs/topics/templates/#overruling-templates-at-runtime). + - `fields` - An object of form fields to add to the request, to make use of in the Assembly instructions via [Assembly variables](https://transloadit.com/docs#assembly-variables). + - `notify_url` - Transloadit can send a Pingback to your server when the Assembly is completed. We'll send the Assembly Status in JSON encoded string inside a transloadit field in a multipart POST request to the URL supplied here. +- `files` - An object (key-value pairs) containing one or more file paths to upload and use in your Assembly. The _key_ is the _field name_ and the _value_ is the path to the file to be uploaded. The _field name_ and the file's name may be used in the ([Assembly instructions](https://transloadit.com/docs/topics/assembly-instructions/)) (`params`.`steps`) to refer to the particular file. See example below. + - `'fieldName': '/path/to/file'` + - more files... +- `uploads` - An object (key-value pairs) containing one or more files to upload and use in your Assembly. The _key_ is the _file name_ and the _value_ is the _content_ of the file to be uploaded. _Value_ can be one of many types: + - `'fieldName': (Readable | Buffer | TypedArray | ArrayBuffer | string | Iterable | AsyncIterable | Promise)`. + - more uploads... +- `waitForCompletion` - A boolean (default is `false`) to indicate whether you want to wait for the Assembly to finish with all encoding results present before the promise is fulfilled. If `waitForCompletion` is `true`, this SDK will poll for status updates and fulfill the promise when all encoding work is done. +- `timeout` - Number of milliseconds to wait before aborting (default `86400000`: 24 hours). +- `onUploadProgress` - An optional function that will be periodically called with the file upload progress, which is an with an object containing: + - `uploadedBytes` - Number of bytes uploaded so far. + - `totalBytes` - Total number of bytes to upload or `undefined` if unknown (Streams). +- `onAssemblyProgress` - Once the Assembly has started processing this will be periodically called with the _Assembly Execution Status_ (result of `getAssembly`) **only if `waitForCompletion` is `true`**. +- `chunkSize` - (for uploads) a number indicating the maximum size of a tus `PATCH` request body in bytes. Default to `Infinity` for file uploads and 50MB for streams of unknown length. See [tus-js-client](https://github.com/tus/tus-js-client/blob/master/docs/api.md#chunksize). +- `uploadConcurrency` - Maximum number of concurrent tus file uploads to occur at any given time (default 10.) +- `uploadBehavior` - Controls how uploads are handled: + - `await` (default) waits for all uploads to finish. + - `background` starts uploads and returns once upload URLs are created. + - `none` returns upload URLs without uploading any bytes. + - When `uploadBehavior` is not `await`, `waitForCompletion` is ignored. + +**NOTE**: Make sure the key in `files` and `uploads` is not one of `signature`, `params` or `max_size`. + +When `uploadBehavior` is `background` or `none`, the resolved Assembly object includes +`upload_urls` with a map of field names to tus upload URLs. + +Example code showing all options: + +```js +await transloadit.createAssembly({ + files: { + file1: '/path/to/file.jpg' + // ... + }, + uploads: { + 'file2.bin': Buffer.from([0, 0, 7]), // A buffer + 'file3.txt': 'file contents', // A string + 'file4.jpg': process.stdin // A stream + // ... + }, + params: { + steps: { ... }, + template_id: 'MY_TEMPLATE_ID', + fields: { + field1: 'Field value', + // ... + }, + notify_url: 'https://example.com/notify-url', + }, + waitForCompletion: true, + timeout: 60000, + onUploadProgress, + onAssemblyProgress, +}) +``` + +Example `onUploadProgress` and `onAssemblyProgress` handlers: + +```javascript +function onUploadProgress({ uploadedBytes, totalBytes }) { + // NOTE: totalBytes may be undefined + console.log(`♻️ Upload progress polled: ${uploadedBytes} of ${totalBytes} bytes uploaded.`) +} +function onAssemblyProgress(assembly) { + console.log( + `♻️ Assembly progress polled: ${assembly.error ? assembly.error : assembly.ok} ${ + assembly.assembly_id + } ... ` + ) +} +``` + +**Tip:** `createAssembly` returns a `Promise` with an extra property `assemblyId`. This can be used to retrieve the Assembly ID before the Assembly has even been created. Useful for debugging by logging this ID when the request starts, for example: + +```js +const promise = transloadit.createAssembly(options) +console.log('Creating', promise.assemblyId) +const status = await promise +``` + +See also: + +- [API documentation](https://transloadit.com/docs/api/assemblies-post/) +- Error codes and retry logic below + +#### async lintAssemblyInstructions(options) + +Lint Assembly Instructions locally using the same linter as the API. +If you provide a `templateId`, the template content is fetched and merged with your instructions +before linting (matching the API's runtime merge behavior). If the template sets +`allow_steps_override=false`, providing steps will throw `TEMPLATE_DENIES_STEPS_OVERRIDE`. + +The `options` object accepts: + +- `assemblyInstructions` - Assembly Instructions as a JSON string, a full instructions object, or a steps-only object. + If no `steps` property is present, the object is treated as steps. +- `templateId` - Optional template ID to merge before linting. +- `fatal` - `'error' | 'warning'` (default: `'error'`). When set to `'warning'`, warnings are treated as fatal. +- `fix` - Apply auto-fixes where possible. If `true`, the result includes `fixedInstructions`. + +The method returns: + +- `success` - `true` when no fatal issues are found. +- `issues` - Array of lint issues (each includes `code`, `type`, `row`, `column`, and `desc`). +- `fixedInstructions` - The fixed JSON string when `fix` is `true` (steps-only inputs return steps-only JSON). + +Example: + +```js +const result = await transloadit.lintAssemblyInstructions({ + assemblyInstructions: { + resize: { robot: '/image/resize', use: ':original', width: 100, height: 100 }, + }, + fatal: 'warning', +}) + +if (!result.success) { + console.log(result.issues) +} +``` + +#### async listAssemblies(params) + +Retrieve Assemblies according to the given `params`. + +Valid params can be `page`, `pagesize`, `type`, `fromdate`, `todate` and `keywords`. Please consult the [API documentation](https://transloadit.com/docs/api/assemblies-get/) for details. + +The method returns an object containing these properties: + +- `items`: An `Array` of up to `pagesize` Assemblies +- `count`: Total number of Assemblies + +#### streamAssemblies(params) + +Creates an `objectMode` `Readable` stream that automates handling of `listAssemblies` pagination. It accepts the same `params` as `listAssemblies`. + +This can be used to iterate through Assemblies: + +```javascript +const assemblyStream = transloadit.streamAssemblies({ fromdate: '2016-08-19 01:15:00 UTC' }) + +assemblyStream.on('readable', function () { + const assembly = assemblyStream.read() + if (assembly == null) console.log('end of stream') + + console.log(assembly.id) +}) +``` + +Results can also be piped. Here's an example using +[through2](https://github.com/rvagg/through2): + +```javascript +const assemblyStream = transloadit.streamAssemblies({ fromdate: '2016-08-19 01:15:00 UTC' }) + +assemblyStream + .pipe( + through.obj(function (chunk, enc, callback) { + this.push(chunk.id + '\n') + callback() + }) + ) + .pipe(fs.createWriteStream('assemblies.txt')) +``` + +#### async getAssembly(assemblyId) + +Retrieves the JSON status of the Assembly identified by the given `assemblyId`. See [API documentation](https://transloadit.com/docs/api/assemblies-assembly-id-get/). + +#### async cancelAssembly(assemblyId) + +Removes the Assembly identified by the given `assemblyId` from the memory of the Transloadit machines, ultimately cancelling it. This does not delete the Assembly from the database - you can still access it on `https://transloadit.com/assemblies/{assembly_id}` in your Transloadit account. This also does not delete any files associated with the Assembly from the Transloadit servers. See [API documentation](https://transloadit.com/docs/api/assemblies-assembly-id-delete/). + +#### async replayAssembly(assemblyId, params) + +Replays the Assembly identified by the given `assemblyId` (required argument). Optionally you can also provide a `notify_url` key inside `params` if you want to change the notification target. See [API documentation](https://transloadit.com/docs/api/assemblies-assembly-id-replay-post/) for more info about `params`. + +The response from the `replayAssembly` is minimal and does not contain much information about the replayed assembly. Please call `getAssembly` or `awaitAssemblyCompletion` after replay to get more information: + +```js +const replayAssemblyResponse = await transloadit.replayAssembly(failedAssemblyId) + +const assembly = await transloadit.getAssembly(replayAssemblyResponse.assembly_id) +// Or +const completedAssembly = await transloadit.awaitAssemblyCompletion( + replayAssemblyResponse.assembly_id +) +``` + +#### async awaitAssemblyCompletion(assemblyId, opts) + +This function will continously poll the specified Assembly `assemblyId` and resolve when it is done uploading and executing (until `result.ok` is no longer `ASSEMBLY_UPLOADING`, `ASSEMBLY_EXECUTING` or `ASSEMBLY_REPLAYING`). It resolves with the same value as `getAssembly`. + +`opts` is an object with the keys: + +- `onAssemblyProgress` - A progress function called on each poll. See `createAssembly` +- `timeout` - How many milliseconds until polling times out (default: no timeout) +- `interval` - Poll interval in milliseconds (default `1000`) +- `signal` - An `AbortSignal` to cancel polling. When aborted, the promise rejects with an `AbortError`. +- `onPoll` - A callback invoked at the start of each poll iteration. Return `false` to stop polling early and resolve with the last known status. Useful for implementing custom cancellation logic (e.g., superseding assemblies in watch mode). + +#### getLastUsedAssemblyUrl() + +Returns the internal url that was used for the last call to `createAssembly`. This is meant to be used for debugging purposes. + +### Assembly notifications + +#### async replayAssemblyNotification(assemblyId, params) + +Replays the notification for the Assembly identified by the given `assemblyId` (required argument). Optionally you can also provide a `notify_url` key inside `params` if you want to change the notification target. See [API documentation](https://transloadit.com/docs/api/assembly-notifications-assembly-id-replay-post/) for more info about `params`. + +### Templates + +Templates are Steps that can be reused. [See example template code](examples/template_api.ts). + +#### async createTemplate(params) + +Creates a template the provided params. The required `params` keys are: + +- `name` - The template name +- `template` - The template JSON object containing its `steps` + +See also [API documentation](https://transloadit.com/docs/api/templates-post/). + +```js +const template = { + steps: { + encode: { + use: ':original', + robot: '/video/encode', + preset: 'ipad-high', + }, + thumbnail: { + use: 'encode', + robot: '/video/thumbnails', + }, + }, +} + +const result = await transloadit.createTemplate({ name: 'my-template-name', template }) +console.log('✅ Template created with template_id', result.id) +``` + +#### async editTemplate(templateId, params) + +Updates the template represented by the given `templateId` with the new value. The `params` works just like the one from the `createTemplate` call. See [API documentation](https://transloadit.com/docs/api/templates-template-id-put/). + +#### async getTemplate(templateId) + +Retrieves the name and the template JSON for the template represented by the given `templateId`. See [API documentation](https://transloadit.com/docs/api/templates-template-id-get/). + +#### async deleteTemplate(templateId) + +Deletes the template represented by the given `templateId`. See [API documentation](https://transloadit.com/docs/api/templates-template-id-delete/). + +#### async listTemplates(params) + +Retrieve all your templates. See [API documentation](https://transloadit.com/docs/api/templates-template-id-get/) for more info about `params`. + +The method returns an object containing these properties: + +- `items`: An `Array` of up to `pagesize` templates +- `count`: Total number of templates + +#### streamTemplates(params) + +Creates an `objectMode` `Readable` stream that automates handling of `listTemplates` pagination. Similar to `streamAssemblies`. + +### Template Credentials + +Template Credentials allow you to store third-party credentials (e.g., AWS S3, Google Cloud Storage, FTP) securely on Transloadit for use in your Assembly Instructions. + +#### async createTemplateCredential(params) + +Creates a new Template Credential. The `params` object should contain the credential configuration. See [API documentation](https://transloadit.com/docs/api/template-credentials-post/). + +#### async editTemplateCredential(credentialId, params) + +Updates an existing Template Credential identified by `credentialId`. See [API documentation](https://transloadit.com/docs/api/template-credentials-credential-id-put/). + +#### async deleteTemplateCredential(credentialId) + +Deletes the Template Credential identified by `credentialId`. See [API documentation](https://transloadit.com/docs/api/template-credentials-credential-id-delete/). + +#### async getTemplateCredential(credentialId) + +Retrieves the Template Credential identified by `credentialId`. See [API documentation](https://transloadit.com/docs/api/template-credentials-credential-id-get/). + +#### async listTemplateCredentials(params) + +Lists all Template Credentials. See [API documentation](https://transloadit.com/docs/api/template-credentials-get/). + +#### streamTemplateCredentials(params) + +Creates an `objectMode` `Readable` stream that automates handling of `listTemplateCredentials` pagination. Similar to `streamAssemblies`. + +### Other + +#### setDefaultTimeout(timeout) + +Same as `constructor` `timeout` option: Set the default timeout (in milliseconds) for all requests (except `createAssembly`) + +#### async getBill(date) + +Retrieves the billing data for a given `date` string with format `YYYY-MM`. See [API documentation](https://transloadit.com/docs/api/bill-date-get/). + +#### calcSignature(params) + +Calculates a signature for the given `params` JSON object. If the `params` object does not include an `authKey` or `expires` keys (and their values) in the `auth` sub-key, then they are set automatically. + +This function returns an object with the key `signature` (containing the calculated signature string) and a key `params`, which contains the stringified version of the passed `params` object (including the set expires and authKey keys). + +See [Signature Generation](#signature-generation) in the CLI section for command-line usage. + +#### getSignedSmartCDNUrl(params) + +Constructs a signed Smart CDN URL, as defined in the [API documentation](https://transloadit.com/docs/topics/signature-authentication/#smart-cdn). `params` must be an object with the following properties: + +- `workspace` - Workspace slug (required) +- `template` - Template slug or template ID (required) +- `input` - Input value that is provided as `${fields.input}` in the template (required) +- `urlParams` - Object with additional parameters for the URL query string (optional) +- `expiresAt` - Expiration timestamp of the signature in milliseconds since UNIX epoch. Defaults to 1 hour from now. (optional) + +Example: + +```js +const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) +const url = client.getSignedSmartCDNUrl({ + workspace: 'foo_workspace', + template: 'foo_template', + input: 'foo_input', + urlParams: { + foo: 'bar', + }, +}) + +// url is: +// https://foo_workspace.tlcdn.com/foo_template/foo_input?auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256:9548915ec70a5f0d05de9497289e792201ceec19a526fe315f4f4fd2e7e377ac +``` + +### Errors + +Any errors originating from Node.js will be passed on and we use [GOT](https://github.com/sindresorhus/got) v11 for HTTP requests. [Errors from `got`](https://github.com/sindresorhus/got/tree/v11.8.6?tab=readme-ov-file#errors) will also be passed on, _except_ the `got.HTTPError` which will be replaced with a `transloadit.ApiError`, which will have its `cause` property set to the instance of the original `got.HTTPError`. `transloadit.ApiError` has these properties: + +- `code` (`string`) - [The Transloadit API error code](https://transloadit.com/docs/api/response-codes/#error-codes). +- `rawMessage` (`string`) - A textual representation of the Transloadit API error. +- `reason` (`string`) - Additional information about the Transloadit API error. +- `assemblyId`: (`string`) - If the request is related to an assembly, this will be the ID of the assembly. +- `assemblySslUrl` (`string`) - If the request is related to an assembly, this will be the SSL URL to the assembly . + +To identify errors you can either check its props or use `instanceof`, e.g.: + +```js +try { + await transloadit.createAssembly(options) +} catch (err) { + if (err instanceof got.TimeoutError) { + return console.error('The request timed out', err) + } + if (err.code === 'ENOENT') { + return console.error('Cannot open file', err) + } + if (err instanceof ApiError && err.code === 'ASSEMBLY_INVALID_STEPS') { + return console.error('Invalid Assembly Steps', err) + } +} +``` + +**Note:** Assemblies that have an error status (`assembly.error`) will only result in an error being thrown from `createAssembly` and `replayAssembly`. For other Assembly methods, no errors will be thrown, but any error can be found in the response's `error` property (also `ApiError.code`). + +- [More information on Transloadit errors (`ApiError.code`)](https://transloadit.com/docs/api/response-codes/#error-codes) +- [More information on request errors](https://github.com/sindresorhus/got#errors) + +### Rate limiting & auto retry + +There are three kinds of retries: + +#### Retry on rate limiting (`maxRetries`, default `5`) + +All functions of the client automatically obey all rate limiting imposed by Transloadit (e.g. `RATE_LIMIT_REACHED`), so there is no need to write your own wrapper scripts to handle rate limits. The SDK will by default retry requests **5 times** with auto back-off (See `maxRetries` constructor option). + +#### GOT HTTP retries (`gotRetry`, default `{ limit: 0 }`) + +Because we use [got](https://github.com/sindresorhus/got) under the hood, you can pass a `gotRetry` constructor option which is passed on to `got`. This offers great flexibility for handling retries on network errors and HTTP status codes with auto back-off. See [`got` `retry` object documentation](https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md). + +**Note that the above `maxRetries` option does not affect the `gotRetry` logic.** + +#### Validate API responses (`validateResponses`, default `false`) + +As we have ported the JavaScript SDK to TypeScript in v4, we are now also validating API responses using `zod` schemas. Having schema validation enabled (`true`), guarantees that the data returned by the SDK adheres to the TypeScript types of this SDK. However we are still working on improving the schemas and they are not yet 100% complete. This means that if you hit a bug in the schemas, a `zod` schema validation error will be thrown. If you encounter such an error, please report it and we will fix it as soon as possible. If you set this option to `false`, schema validation will be disabled, and you won't get any such errors, however the TypeScript types will not protect you should such a bug be encountered. + +#### Custom retry logic + +If you want to retry on other errors, please see the [retry example code](examples/retry.ts). + +- https://transloadit.com/docs/api/rate-limiting/ +- https://transloadit.com/blog/2012/04/introducing-rate-limiting/ + +## Debugging + +This project uses [debug](https://github.com/visionmedia/debug) so you can run node with the `DEBUG=transloadit` evironment variable to enable verbose logging. Example: + +```bash +DEBUG=transloadit* node examples/template_api.ts +``` + +## Maintainers + +- [Mikael Finstad](https://github.com/mifi) + +### Changelog + +See [Releases](https://github.com/transloadit/node-sdk/releases) + +## Attribution + +Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `transloadit` npm name. You can still access his code under [`v0.0.0`](https://www.npmjs.com/package/transloadit/v/0.0.0). + +## License + +[MIT](LICENSE) © [Transloadit](https://transloadit.com) + +## Development + +See [CONTRIBUTING](./CONTRIBUTING.md). + + +