Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**/*.test.ts
vitest.config.ts
8 changes: 4 additions & 4 deletions nodes/Heygen/HeygenNode.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
INodeTypeDescription,
} from 'n8n-workflow';

import { NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';

// operations
import { heygenResource, heygenOperations } from './operations/operations_list';
Expand Down Expand Up @@ -37,14 +37,14 @@ export class HeygenNode implements INodeType {
displayName: 'HeyGen',
name: 'heygenNode',
icon: 'file:heygen.svg',
group: ['ai', 'contentCreation'],
group: ['ai', 'contentCreation'] as unknown as INodeTypeDescription['group'],
version: 1,
description: 'HeyGen community node',
defaults: {
name: 'HeyGen',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
usableAsTool: true,

// credential setup
Expand Down
4 changes: 2 additions & 2 deletions nodes/Heygen/HeygenTrigger.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
IWebhookResponseData,
} from 'n8n-workflow';

import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { handleHeygenWebhookEvent } from './operations/webhooks/webhook';

export class HeygenTrigger implements INodeType {
Expand All @@ -19,7 +19,7 @@ export class HeygenTrigger implements INodeType {
defaults: { name: 'HeyGen Trigger' },

inputs: [],
outputs: [NodeConnectionType.Main],
outputs: [NodeConnectionTypes.Main],

webhooks: [
{
Expand Down
105 changes: 105 additions & 0 deletions nodes/Heygen/methods/getLists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ILoadOptionsFunctions } from 'n8n-workflow';

vi.mock('../shared/shared_functions', () => ({
heyGenApiRequest: vi.fn(),
}));

import { getTemplatesList } from './getLists';
import { heyGenApiRequest } from '../shared/shared_functions';

const mockLoadOptions = {
getCurrentNodeParameter: vi.fn().mockReturnValue('apiKey'),
getCredentials: vi.fn().mockResolvedValue({ apiKey: 'test-key' }),
} as unknown as ILoadOptionsFunctions;

describe('getTemplatesList', () => {
beforeEach(() => {
vi.mocked(heyGenApiRequest).mockReset();
});

it('maps GET /v2/templates data.templates to dropdown options', async () => {
vi.mocked(heyGenApiRequest).mockResolvedValue({
error: null,
data: {
templates: [
{
template_id: 'a1b2c3d4e5f6789012345678901234ab',
name: 'My product video',
thumbnail_image_url: 'https://example.com/thumb.jpg',
aspect_ratio: 'landscape',
},
{
template_id: 'b2c3d4e5f6789012345678901234abcd',
name: 'Vertical promo',
thumbnail_image_url: null,
aspect_ratio: 'portrait',
},
],
},
});

const options = await getTemplatesList.call(mockLoadOptions);

expect(heyGenApiRequest).toHaveBeenCalledWith(
'GET',
'/templates',
{},
{},
{},
'api',
'v2',
);
expect(options).toEqual([
{ name: 'My product video (landscape)', value: 'a1b2c3d4e5f6789012345678901234ab' },
{ name: 'Vertical promo (portrait)', value: 'b2c3d4e5f6789012345678901234abcd' },
]);
});

it('disambiguates duplicate name+aspect labels with template id prefix', async () => {
vi.mocked(heyGenApiRequest).mockResolvedValue({
data: {
templates: [
{ template_id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', name: 'Same', aspect_ratio: 'landscape' },
{ template_id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', name: 'Same', aspect_ratio: 'landscape' },
],
},
});

const options = await getTemplatesList.call(mockLoadOptions);

expect(options[0].name).toBe('Same (landscape) — aaaaaaaa…');
expect(options[1].name).toBe('Same (landscape) — bbbbbbbb…');
expect(options[0].value).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
expect(options[1].value).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
});

it('throws when top-level error is set', async () => {
vi.mocked(heyGenApiRequest).mockResolvedValue({
error: 'rate limited',
data: {},
});

await expect(getTemplatesList.call(mockLoadOptions)).rejects.toThrow('rate limited');
});

it('throws when code is present and not 100', async () => {
vi.mocked(heyGenApiRequest).mockResolvedValue({
code: 401,
msg: 'Unauthorized',
data: {},
});

await expect(getTemplatesList.call(mockLoadOptions)).rejects.toThrow('Unauthorized');
});

it('accepts code 100 with templates', async () => {
vi.mocked(heyGenApiRequest).mockResolvedValue({
code: 100,
data: { templates: [{ template_id: 'x', name: 'T' }] },
});

const options = await getTemplatesList.call(mockLoadOptions);
expect(options).toEqual([{ name: 'T', value: 'x' }]);
});
});
68 changes: 61 additions & 7 deletions nodes/Heygen/methods/getLists.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,69 @@
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { heyGenApiRequest } from '../shared/shared_functions';

type TemplatesListResponse = {
error?: unknown;
code?: number;
msg?: string | null;
message?: string | null;
data?: { templates?: unknown };
};

/** GET https://api.heygen.com/v2/templates — populates the Create Template Video template dropdown. */
export async function getTemplatesList(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const response = await heyGenApiRequest.call(this, 'GET', '/templates', {}, {}, {}, 'api', 'v2');
const response = (await heyGenApiRequest.call(
this,
'GET',
'/templates',
{},
{},
{},
'api',
'v2',
)) as TemplatesListResponse;

if (response?.error) {
const err = response.error;
const msg = typeof err === 'string' ? err : JSON.stringify(err);
throw new Error(`HeyGen templates request failed: ${msg}`);
}

if (response?.code !== undefined && response.code !== null && response.code !== 100) {
const detail = response.msg ?? response.message ?? String(response.code);
throw new Error(`HeyGen templates request failed: ${detail}`);
}

const templates = response?.data?.templates;
if (!Array.isArray(templates)) {
throw new Error('Invalid template response: expected data.templates array');
}

type TemplateRow = { template_id?: string; name?: string; aspect_ratio?: string };
const rows = templates as TemplateRow[];

const baseLabel = (template: TemplateRow, id: string) => {
const baseName = template.name || id;
const ratio = template.aspect_ratio ? ` (${template.aspect_ratio})` : '';
return `${baseName}${ratio}`;
};

if (!response?.data?.templates || !Array.isArray(response.data.templates)) {
throw new Error('Invalid template response format');
const bases = rows.map((t) => {
const id = t.template_id;
if (!id) {
throw new Error('Invalid template entry: missing template_id');
}
return baseLabel(t, id);
});
const baseCounts = new Map<string, number>();
for (const b of bases) {
baseCounts.set(b, (baseCounts.get(b) ?? 0) + 1);
}

return response.data.templates.map((template: any) => ({
name: template.name || template.template_id,
value: template.template_id,
}));
return rows.map((template, i) => {
const id = template.template_id as string;
const base = bases[i];
const duplicate = (baseCounts.get(base) ?? 0) > 1;
const name = duplicate ? `${base} — ${id.slice(0, 8)}…` : base;
return { name, value: id };
});
}
33 changes: 6 additions & 27 deletions nodes/Heygen/operations/video_creation/api_requests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { IDataObject } from 'n8n-workflow';
import { heyGenApiRequest } from '../../shared/shared_functions';
import { heyGenApiRequest } from '../../shared/shared_functions';
import { buildTemplateVariablesFromRows } from './template_variables';

export async function createAvatarVideoApi(
this: IExecuteFunctions,
Expand Down Expand Up @@ -110,9 +111,11 @@ export async function createTemplateVideoApi(
caption: this.getNodeParameter('caption', i) as boolean,
};

body.test = this.getNodeParameter('additionalFields.test', i, false) as boolean;

const optionalFields = ['title', 'callbackId', 'callbackUrl', 'folderId'];
for (const field of optionalFields) {
const value = this.getNodeParameter(field, i, '') as string;
const value = this.getNodeParameter(`additionalFields.${field}`, i, '') as string;
if (value) body[field.replace(/([A-Z])/g, '_$1').toLowerCase()] = value;
}

Expand All @@ -128,32 +131,8 @@ export async function createTemplateVideoApi(
throw new NodeOperationError(this.getNode(), 'You must provide a Template ID manually or select one from the list.');
}

// Template Variables
const templateVariables = this.getNodeParameter('templateVariables.variable', i, []) as IDataObject[];
const variables: Record<string, IDataObject> = {};

for (const v of templateVariables) {
const key = v.key as string;
const type = v.type as string;

if (!key || !type) continue;

const variable: IDataObject = {
name: key, //v.name || key, - we show user only key field, and fill variable name just in request
type,
properties: {},
};

if (type === 'text') {
variable.properties = { content: v.textContent || '' };
} else if (type === 'voice') {
variable.properties = { voice_id: v.voiceId || '' };
}

variables[key] = variable;
}

body.variables = variables;
body.variables = buildTemplateVariablesFromRows(templateVariables);

return await heyGenApiRequest.call(this,'POST',`/template/${template_id}/generate`,body,{},{},'api','v2',
);
Expand Down
Loading