diff --git a/nodes/WizPixelFlow/WizPixelFlow.node.ts b/nodes/WizPixelFlow/WizPixelFlow.node.ts new file mode 100644 index 0000000..8762653 --- /dev/null +++ b/nodes/WizPixelFlow/WizPixelFlow.node.ts @@ -0,0 +1,198 @@ +import { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeOperationError, +} from 'n8n-workflow'; + +const OUTPUT_FORMATS = [ + { name: 'Leaderboard 728x90', value: 'leaderboard_728x90' }, + { name: 'Medium Rectangle 300x250', value: 'medium_rectangle_300x250' }, + { name: 'Half Page 300x600', value: 'half_page_300x600' }, + { name: 'Large Rectangle 336x280', value: 'large_rectangle_336x280' }, + { name: 'Billboard 970x250', value: 'billboard_970x250' }, + { name: 'Wide Skyscraper 160x600', value: 'wide_skyscraper_160x600' }, + { name: 'Skyscraper 120x600', value: 'skyscraper_120x600' }, + { name: 'Small Rectangle 300x100', value: 'small_rectangle_300x100' }, + { name: 'Square 250x250', value: 'square_250x250' }, + { name: 'Small Square 200x200', value: 'small_square_200x200' }, + { name: 'Banner 468x60', value: 'banner_468x60' }, + { name: 'Half Banner 234x60', value: 'half_banner_234x60' }, + { name: 'Micro Bar 88x31', value: 'micro_bar_88x31' }, + { name: 'Square Post 1080x1080', value: 'square_post_1080x1080' }, + { name: 'Portrait Post 1080x1350', value: 'portrait_post_1080x1350' }, + { name: 'Stories 1080x1920', value: 'stories_1080x1920' }, + { name: 'Shorts 1080x1920', value: 'shorts_1080x1920' }, + { name: 'Landscape 1280x720', value: 'landscape_1280x720' }, + { name: 'LinkedIn Banner 1584x396', value: 'linkedin_banner_1584x396' }, + { name: 'LinkedIn Square 1200x1200', value: 'linkedin_square_1200x1200' }, + { name: 'Twitter Card 1200x628', value: 'twitter_card_1200x628' }, + { name: 'Twitter Square 1080x1080', value: 'twitter_square_1080x1080' }, +]; + +export class WizPixelFlow implements INodeType { + description: INodeTypeDescription = { + displayName: 'WizPixel Flow', + name: 'wizPixelFlow', + icon: 'file:wizpixel-flow.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"]}}', + description: 'Render HTML5 banners and MP4 videos with WizPixel Flow', + defaults: { name: 'WizPixel Flow' }, + inputs: ['main'], + outputs: ['main'], + credentials: [{ name: 'wizPixelFlowApi', required: true }], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { name: 'Render', value: 'render', description: 'Submit a render job' }, + { name: 'Get Job', value: 'getJob', description: 'Get status of a render job' }, + { name: 'List Templates', value: 'listTemplates', description: 'List available templates' }, + ], + default: 'render', + }, + { + displayName: 'Template ID', + name: 'templateId', + type: 'string', + default: '', + required: true, + displayOptions: { show: { operation: ['render'] } }, + description: 'The ID of the Flow template to render', + }, + { + displayName: 'Output Formats', + name: 'outputFormats', + type: 'multiOptions', + options: OUTPUT_FORMATS, + default: ['leaderboard_728x90', 'medium_rectangle_300x250'], + required: true, + displayOptions: { show: { operation: ['render'] } }, + }, + { + displayName: 'Locales', + name: 'locales', + type: 'string', + default: 'en', + displayOptions: { show: { operation: ['render'] } }, + description: 'Comma-separated locale codes, e.g. en,it,fr', + }, + { + displayName: 'Variables (JSON)', + name: 'variables', + type: 'json', + default: '{}', + displayOptions: { show: { operation: ['render'] } }, + description: 'Key/value pairs to substitute into {{variable}} placeholders', + }, + { + displayName: 'Include Video (MP4)', + name: 'includeVideo', + type: 'boolean', + default: false, + displayOptions: { show: { operation: ['render'] } }, + }, + { + displayName: 'Wait for Completion', + name: 'waitForCompletion', + type: 'boolean', + default: true, + displayOptions: { show: { operation: ['render'] } }, + description: 'Poll until the job is done and return the ZIP download URL', + }, + { + displayName: 'Poll Interval (seconds)', + name: 'pollInterval', + type: 'number', + default: 5, + displayOptions: { show: { operation: ['render'], waitForCompletion: [true] } }, + }, + { + displayName: 'Max Wait (seconds)', + name: 'maxWait', + type: 'number', + default: 300, + displayOptions: { show: { operation: ['render'], waitForCompletion: [true] } }, + }, + { + displayName: 'Job ID', + name: 'jobId', + type: 'string', + default: '', + required: true, + displayOptions: { show: { operation: ['getJob'] } }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + typeOptions: { minValue: 1, maxValue: 200 }, + displayOptions: { show: { operation: ['listTemplates'] } }, + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const credentials = await this.getCredentials('wizPixelFlowApi'); + const baseUrl = (credentials.baseUrl as string).replace(/\/$/, ''); + const apiKey = credentials.apiKey as string; + const headers = { 'X-WPF-API-Key': apiKey, 'Content-Type': 'application/json' }; + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + const operation = this.getNodeParameter('operation', i) as string; + try { + if (operation === 'render') { + const templateId = this.getNodeParameter('templateId', i) as string; + const outputFormats = this.getNodeParameter('outputFormats', i) as string[]; + const localesRaw = this.getNodeParameter('locales', i) as string; + const variablesRaw = this.getNodeParameter('variables', i) as string; + const includeVideo = this.getNodeParameter('includeVideo', i) as boolean; + const waitForCompletion = this.getNodeParameter('waitForCompletion', i) as boolean; + const pollInterval = this.getNodeParameter('pollInterval', i) as number; + const maxWait = this.getNodeParameter('maxWait', i) as number; + const locales = localesRaw.split(',').map((l) => l.trim()).filter(Boolean); + const variables = typeof variablesRaw === 'string' ? JSON.parse(variablesRaw) : variablesRaw; + const body = { template_id: templateId, formats: outputFormats, locales, variables, include_video: includeVideo }; + const submitResp = await this.helpers.httpRequest({ method: 'POST', url: `${baseUrl}/render`, headers, body: JSON.stringify(body) }); + const parsed = typeof submitResp === 'string' ? JSON.parse(submitResp) : submitResp; + const jobId = parsed.job_id; + if (!waitForCompletion) { returnData.push({ json: parsed }); continue; } + const deadline = Date.now() + maxWait * 1000; + let jobData: Record = {}; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollInterval * 1000)); + const pollResp = await this.helpers.httpRequest({ method: 'GET', url: `${baseUrl}/jobs/${jobId}`, headers }); + jobData = typeof pollResp === 'string' ? JSON.parse(pollResp) : pollResp; + const job = (jobData as { job?: { status?: string } }).job; + if (job?.status === 'done' || job?.status === 'failed') break; + } + returnData.push({ json: jobData }); + } else if (operation === 'getJob') { + const jobId = this.getNodeParameter('jobId', i) as string; + const resp = await this.helpers.httpRequest({ method: 'GET', url: `${baseUrl}/jobs/${jobId}`, headers }); + returnData.push({ json: typeof resp === 'string' ? JSON.parse(resp) : resp }); + } else if (operation === 'listTemplates') { + const limit = this.getNodeParameter('limit', i) as number; + const resp = await this.helpers.httpRequest({ method: 'GET', url: `${baseUrl}/templates?limit=${limit}`, headers }); + returnData.push({ json: typeof resp === 'string' ? JSON.parse(resp) : resp }); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { error: (error as Error).message }, pairedItem: i }); + } else { + throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i }); + } + } + } + return [returnData]; + } +}