diff --git a/.gitignore b/.gitignore index d6fc5fac..e2f3ab82 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ env.sh !.yarn/sdks !.yarn/versions .aider* +.DS_Store diff --git a/package.json b/package.json index 630d7233..93e33222 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "is-stream": "^2.0.1", "p-map": "^4.0.0", "tus-js-client": "^4.3.1", + "type-fest": "^4.39.1", "zod": "^3.24.2" }, "devDependencies": { diff --git a/src/alphalib/types/robots/_index.ts b/src/alphalib/types/robots/_index.ts index 3c27e53c..f040742c 100644 --- a/src/alphalib/types/robots/_index.ts +++ b/src/alphalib/types/robots/_index.ts @@ -325,6 +325,7 @@ const robotStepsInstructionsWithHiddenFields = [ /** * Public robot instructions */ +export type RobotsSchema = z.infer export const robotsSchema = z.discriminatedUnion('robot', [...robotStepsInstructions]) export const robotsWithHiddenFieldsSchema = z.discriminatedUnion('robot', [ ...robotStepsInstructionsWithHiddenFields, diff --git a/src/alphalib/types/robots/_instructions-primitives.ts b/src/alphalib/types/robots/_instructions-primitives.ts index 65319dca..ca0da573 100644 --- a/src/alphalib/types/robots/_instructions-primitives.ts +++ b/src/alphalib/types/robots/_instructions-primitives.ts @@ -1,3 +1,4 @@ +import type { Replace } from 'type-fest' import { z } from 'zod' import { stackVersions } from '../stackVersions.ts' @@ -315,11 +316,72 @@ Selects the FFmpeg stack version to use for encoding. These versions reflect rea `), }) -function preprocessPreset(preset: unknown) { - return typeof preset === 'string' ? preset.replaceAll('_', '-') : preset +/** + * Replace all underscores with hyphens. + * + * @param preset + * The input preset which may contain underscores. + * @returns + * The hyphenated preset. + */ +function transformPreset(preset: T): Replace { + return preset.replaceAll('_', '-') as Replace +} + +/** + * Convert a preset with hyphens to any underscore/hyphen combination. + * + * @template T + * The preset to process. + */ +type ReplacePreset = T extends `${infer T0}-${infer Tail}` + ? T | `${T0}-${ReplacePreset}` | `${T0}_${ReplacePreset}` + : T + +/** + * Generate all possible underscore/hyphen combinations of a preset. + * + * @param chunks + * A normalized preset split on hyphens. + * @returns + * An iterable that yields all possible combinations. + */ +function* generateCombinations(chunks: string[]): Iterable { + if (chunks.length === 0) { + return + } + + if (chunks.length === 1) { + yield chunks[0] + } + + const [head, ...remaining] = chunks + for (const result of generateCombinations(remaining)) { + yield `${head}-${result}` + yield `${head}_${result}` + } +} + +/** + * Create all possible preset combinations from a list of normalized presets. + * + * @param inputs + * The hyphenated presets. + * @returns + * An array of all possible combinations. + */ +function createPresets( + inputs: T[], +): readonly [ReplacePreset, ...ReplacePreset[]] { + const results: string[] = [] + for (const input of inputs) { + results.push(...generateCombinations(input.split('-'))) + } + + return [...results].sort() as [ReplacePreset, ...ReplacePreset[]] } -const audioPresets = [ +const audioPresets = createPresets([ 'aac', 'alac', 'audio/aac', @@ -344,7 +406,7 @@ const audioPresets = [ 'opus', 'speech', 'wav', -] as const +]) /** * A robot that uses FFmpeg to **output** audio. @@ -352,7 +414,7 @@ const audioPresets = [ export type FFmpegAudio = z.infer export const robotFFmpegAudio = robotFFmpeg .extend({ - preset: z.preprocess(preprocessPreset, z.enum(audioPresets)).optional().describe(` + preset: z.enum(audioPresets).transform(transformPreset).optional().describe(` Performs conversion using pre-configured settings. If you specify your own FFmpeg parameters using the Robot's \`ffmpeg\` parameter and you have not specified a preset, then the default \`mp3\` preset is not applied. This is to prevent you from having to override each of the MP3 preset's values manually. @@ -379,9 +441,8 @@ Height of the new video, in pixels. If the value is not specified and the \`preset\` parameter is available, the \`preset\`'s [supplied height](/docs/transcoding/video-encoding/video-presets/) will be implemented. `), preset: z - .preprocess( - preprocessPreset, - z.enum([ + .enum([ + ...createPresets([ 'android-high', 'android-low', 'android', @@ -461,9 +522,10 @@ If the value is not specified and the \`preset\` parameter is available, the \`p 'webm-1080p', 'webm', 'wmv', - ...audioPresets, ]), - ) + ...audioPresets, + ]) + .transform(transformPreset) .optional().describe(` Converts a video according to [pre-configured settings](/docs/transcoding/video-encoding/video-presets/). @@ -834,3 +896,58 @@ While we recommend to use Template Credentials at all times, some use secret: z.string().optional(), }) .strict() + +export type FilterExpression = z.infer +export const filterExpression = z.union([ + z.string(), + z.number(), + z.array(z.union([z.string(), z.number()])), +]) + +export type FilterCondition = z.infer +export const filterCondition = z + .array( + z.union([ + z.tuple([ + filterExpression, + z.union([ + z.literal('==').describe('Equals without type check'), + z.literal('===').describe('Strict equals with type check'), + z.literal('<').describe('Less than'), + z.literal('>').describe('Greater than'), + z.literal('<=').describe('Less or equal'), + z.literal('>=').describe('Greater or equal'), + z.literal('!=').describe('Simple inequality check without type check'), + z.literal('!==').describe('Strict inequality check with type check'), + z + .literal('regex') + .describe( + 'Case-insensitive regular expression based on [RE2](https://github.com/google/re2) `.match()`', + ), + z + .literal('!regex') + .describe( + 'Case-insensitive regular expression based on [RE2](https://github.com/google/re2) `!.match()`', + ), + z + .literal('includes') + .describe( + 'Check if the right element is included in the array, which is represented by the left element', + ), + z + .literal('empty') + .describe( + 'Check if the left element is an empty array, an object without properties, an empty string, the number zero or the boolean false. Leave the third element of the array to be an empty string. It won’t be evaluated.', + ), + z + .literal('!empty') + .describe( + 'Check if the left element is an array with members, an object with at least one property, a non-empty string, a number that does not equal zero or the boolean true. Leave the third element of the array to be an empty string. It won’t be evaluated.', + ), + ]), + filterExpression, + ]), + z.string(), + ]), + ) + .default([]) diff --git a/src/alphalib/types/robots/file-filter.ts b/src/alphalib/types/robots/file-filter.ts index 3963ead4..7d093b7d 100644 --- a/src/alphalib/types/robots/file-filter.ts +++ b/src/alphalib/types/robots/file-filter.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import { robotBase, robotUse } from './_instructions-primitives.ts' +import { filterCondition, robotBase, robotUse } from './_instructions-primitives.ts' import type { RobotMeta } from './_instructions-primitives.ts' export const meta: RobotMeta = { @@ -13,7 +13,7 @@ export const meta: RobotMeta = { filtered: { robot: '/file/filter', use: ':original', - declines: [['${file.size}', '>', '20971520']], + declines: [['${file.size}', '>', '20971520']], error_on_decline: true, error_msg: 'File size must not exceed 20 MB', }, @@ -77,22 +77,14 @@ Examples: As indicated, we charge for this via [🤖/script/run](/docs/transcoding/code-evaluation/script-run/). See also [Dynamic Evaluation](/docs/topics/dynamic-evaluation/) for more details on allowed syntax and behavior. `), - accepts: z - .array( - z.union([z.string(), z.tuple([z.string(), z.string(), z.union([z.string(), z.number()])])]), - ) - .default([]).describe(` + accepts: filterCondition.describe(` Files that match at least one requirement will be accepted, or declined otherwise. If the array is empty, all files will be accepted. Example: \`[["\${file.mime}", "==", "image/gif"]]\`. If the \`condition_type\` parameter is set to \`"and"\`, then all requirements must match for the file to be accepted. `), - declines: z - .array( - z.union([z.string(), z.tuple([z.string(), z.string(), z.union([z.string(), z.number()])])]), - ) - .default([]).describe(` + declines: filterCondition.describe(` Files that match at least one requirement will be declined, or accepted otherwise. Example: \`[["\${file.size}",">","1024"]]\`. diff --git a/src/alphalib/types/robots/image-generate.ts b/src/alphalib/types/robots/image-generate.ts index dbe334ed..9db35500 100644 --- a/src/alphalib/types/robots/image-generate.ts +++ b/src/alphalib/types/robots/image-generate.ts @@ -13,7 +13,7 @@ export const robotImageGenerateInstructionsSchema = robotBase .optional() .describe('Format of the generated image.'), seed: z.number().optional().describe('Seed for the random number generator.'), - aspectRatio: z.string().optional().describe('Aspect ratio of the generated image.'), + aspect_ratio: z.string().optional().describe('Aspect ratio of the generated image.'), height: z.number().optional().describe('Height of the generated image.'), width: z.number().optional().describe('Width of the generated image.'), style: z.string().optional().describe('Style of the generated image.'), diff --git a/tsconfig.build.json b/tsconfig.build.json index eb88fecc..1c4829af 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,6 +1,6 @@ { "include": ["src"], - "exclude": ["coverage"], + "exclude": ["coverage", "dist"], "compilerOptions": { "composite": true, "declaration": true, diff --git a/yarn.lock b/yarn.lock index 7a2718f3..c2e43058 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6786,6 +6786,7 @@ __metadata: prettier: "npm:^3.3.3" temp: "npm:^0.9.4" tus-js-client: "npm:^4.3.1" + type-fest: "npm:^4.39.1" typescript: "npm:^5.7.2" vitest: "npm:^2.1.3" zod: "npm:^3.24.2" @@ -6851,6 +6852,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.39.1": + version: 4.39.1 + resolution: "type-fest@npm:4.39.1" + checksum: 10c0/f5bf302eb2e2f70658be1757aa578f4a09da3f65699b0b12b7ae5502ccea76e5124521a6e6b69540f442c3dc924c394202a2ab58718d0582725c7ac23c072594 + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.2": version: 1.0.2 resolution: "typed-array-buffer@npm:1.0.2"