@assistkick/create 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +18 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +9 -5
- package/templates/assistkick-product-system/packages/backend/src/server.ts +1 -22
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +16 -0
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +20 -6
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +30 -7
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/IterationCommentModal.tsx +80 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +67 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GenerateTTSNode.tsx +52 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RebuildBundleNode.tsx +20 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RenderVideoNode.tsx +72 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +9 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +39 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +30 -1
- package/templates/assistkick-product-system/packages/shared/db/migrations/0014_nifty_punisher.sql +15 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0014_snapshot.json +1545 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +1 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +1 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +247 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +158 -2
- package/templates/assistkick-product-system/tests/video_render_service.test.ts +6 -4
|
@@ -188,6 +188,7 @@ export const workflows = sqliteTable('workflows', {
|
|
|
188
188
|
description: text('description'),
|
|
189
189
|
projectId: text('project_id'),
|
|
190
190
|
featureType: text('feature_type'),
|
|
191
|
+
triggerColumn: text('trigger_column'),
|
|
191
192
|
isDefault: integer('is_default').notNull().default(0),
|
|
192
193
|
graphData: text('graph_data').notNull(),
|
|
193
194
|
createdAt: text('created_at').notNull(),
|
|
@@ -205,7 +205,7 @@ export const emitAssistantUsage = (jsonStr, callback) => {
|
|
|
205
205
|
* @returns {{ spawnClaude: function, spawnCommand: function }}
|
|
206
206
|
*/
|
|
207
207
|
export const createClaudeService = ({ verbose = false, log: logFn }) => {
|
|
208
|
-
const spawnClaude = (prompt, cwd, label = 'claude', { onToolUse, onResult, onTurnUsage, onAssistantText } = {}) => new Promise((resolve, reject) => {
|
|
208
|
+
const spawnClaude = (prompt, cwd, label = 'claude', { onToolUse, onResult, onTurnUsage, onAssistantText } = {} as any) => new Promise((resolve, reject) => {
|
|
209
209
|
const promptPreview = prompt.slice(0, 100).replace(/\n/g, '\\n');
|
|
210
210
|
logFn('CLAUDE', `Spawning ${label} in ${cwd} — prompt length: ${prompt.length} chars`);
|
|
211
211
|
logFn('CLAUDE', `Prompt preview: "${promptPreview}..."`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, mock, beforeEach } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {WorkflowEngine} from "./workflow_engine.js";
|
|
4
4
|
|
|
5
5
|
// ── Default workflow graph (matches seeded data) ──────────────────────
|
|
6
6
|
|
|
@@ -1750,4 +1750,250 @@ describe('WorkflowEngine', () => {
|
|
|
1750
1750
|
assert.deepEqual(nodeExecs['transition_1'].toolCalls, []);
|
|
1751
1751
|
});
|
|
1752
1752
|
});
|
|
1753
|
+
|
|
1754
|
+
// ── Video Workflow Node Type Tests ────────────────────────────────
|
|
1755
|
+
|
|
1756
|
+
describe('handleRebuildBundle', () => {
|
|
1757
|
+
it('calls bundleService.buildBundle and returns success', async () => {
|
|
1758
|
+
const mockBundleService = {
|
|
1759
|
+
buildBundle: mock.fn(async () => ({
|
|
1760
|
+
ready: true,
|
|
1761
|
+
building: false,
|
|
1762
|
+
bundlePath: '/tmp/bundle',
|
|
1763
|
+
lastBuiltAt: '2026-03-11T00:00:00.000Z',
|
|
1764
|
+
error: null,
|
|
1765
|
+
})),
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
const engine = new WorkflowEngine({
|
|
1769
|
+
db: createMockDb() as any,
|
|
1770
|
+
kanban: createMockKanban(),
|
|
1771
|
+
claudeService: createMockClaudeService(),
|
|
1772
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1773
|
+
bundleService: mockBundleService,
|
|
1774
|
+
log: createMockLog(),
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
const node = { id: 'rebuild', type: 'rebuildBundle', data: {} };
|
|
1778
|
+
const result = await (engine as any).handleRebuildBundle(node, defaultContext());
|
|
1779
|
+
|
|
1780
|
+
assert.equal(result.outputLabel, null);
|
|
1781
|
+
assert.equal(result.outputData.ready, true);
|
|
1782
|
+
assert.equal(result.outputData.bundlePath, '/tmp/bundle');
|
|
1783
|
+
assert.equal(mockBundleService.buildBundle.mock.callCount(), 1);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
it('throws when bundle build fails', async () => {
|
|
1787
|
+
const mockBundleService = {
|
|
1788
|
+
buildBundle: mock.fn(async () => ({
|
|
1789
|
+
ready: false,
|
|
1790
|
+
building: false,
|
|
1791
|
+
bundlePath: null,
|
|
1792
|
+
error: 'webpack error',
|
|
1793
|
+
})),
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
const engine = new WorkflowEngine({
|
|
1797
|
+
db: createMockDb() as any,
|
|
1798
|
+
kanban: createMockKanban(),
|
|
1799
|
+
claudeService: createMockClaudeService(),
|
|
1800
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1801
|
+
bundleService: mockBundleService,
|
|
1802
|
+
log: createMockLog(),
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
const node = { id: 'rebuild', type: 'rebuildBundle', data: {} };
|
|
1806
|
+
await assert.rejects(
|
|
1807
|
+
() => (engine as any).handleRebuildBundle(node, defaultContext()),
|
|
1808
|
+
{ message: 'Bundle build failed: webpack error' },
|
|
1809
|
+
);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it('throws when bundleService is not configured', async () => {
|
|
1813
|
+
const engine = new WorkflowEngine({
|
|
1814
|
+
db: createMockDb() as any,
|
|
1815
|
+
kanban: createMockKanban(),
|
|
1816
|
+
claudeService: createMockClaudeService(),
|
|
1817
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1818
|
+
log: createMockLog(),
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
const node = { id: 'rebuild', type: 'rebuildBundle', data: {} };
|
|
1822
|
+
await assert.rejects(
|
|
1823
|
+
() => (engine as any).handleRebuildBundle(node, defaultContext()),
|
|
1824
|
+
{ message: /BundleService not configured/ },
|
|
1825
|
+
);
|
|
1826
|
+
});
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
describe('handleGenerateTTS', () => {
|
|
1830
|
+
it('calls ttsService.generate with resolved script path', async () => {
|
|
1831
|
+
const mockTtsService = {
|
|
1832
|
+
generate: mock.fn(async () => ({
|
|
1833
|
+
processed: 5,
|
|
1834
|
+
generated: 3,
|
|
1835
|
+
skipped: 2,
|
|
1836
|
+
errors: [],
|
|
1837
|
+
durations: { scene1: 5.2, scene2: 3.1 },
|
|
1838
|
+
audioBasePath: '/tmp/audio',
|
|
1839
|
+
})),
|
|
1840
|
+
};
|
|
1841
|
+
|
|
1842
|
+
const engine = new WorkflowEngine({
|
|
1843
|
+
db: createMockDb() as any,
|
|
1844
|
+
kanban: createMockKanban(),
|
|
1845
|
+
claudeService: createMockClaudeService(),
|
|
1846
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1847
|
+
ttsService: mockTtsService,
|
|
1848
|
+
log: createMockLog(),
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
const node = {
|
|
1852
|
+
id: 'tts',
|
|
1853
|
+
type: 'generateTTS',
|
|
1854
|
+
data: { scriptPath: 'script.md', force: true },
|
|
1855
|
+
};
|
|
1856
|
+
const ctx = defaultContext({ worktreePath: '/tmp/wt', projectId: 'proj_1' });
|
|
1857
|
+
const result = await (engine as any).handleGenerateTTS(node, ctx);
|
|
1858
|
+
|
|
1859
|
+
assert.equal(result.outputLabel, null);
|
|
1860
|
+
assert.equal(result.outputData.generated, 3);
|
|
1861
|
+
assert.equal(result.outputData.skipped, 2);
|
|
1862
|
+
assert.equal(result.outputData.audioBasePath, '/tmp/audio');
|
|
1863
|
+
assert.equal(mockTtsService.generate.mock.callCount(), 1);
|
|
1864
|
+
|
|
1865
|
+
const callArgs = mockTtsService.generate.mock.calls[0].arguments[0];
|
|
1866
|
+
assert.ok(callArgs.scriptPath.includes('script.md'));
|
|
1867
|
+
assert.equal(callArgs.force, true);
|
|
1868
|
+
assert.equal(callArgs.projectId, 'proj_1');
|
|
1869
|
+
assert.equal(callArgs.featureId, 'feat_test');
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
it('throws when ttsService is not configured', async () => {
|
|
1873
|
+
const engine = new WorkflowEngine({
|
|
1874
|
+
db: createMockDb() as any,
|
|
1875
|
+
kanban: createMockKanban(),
|
|
1876
|
+
claudeService: createMockClaudeService(),
|
|
1877
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1878
|
+
log: createMockLog(),
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
const node = { id: 'tts', type: 'generateTTS', data: {} };
|
|
1882
|
+
await assert.rejects(
|
|
1883
|
+
() => (engine as any).handleGenerateTTS(node, defaultContext()),
|
|
1884
|
+
{ message: /TtsService not configured/ },
|
|
1885
|
+
);
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
describe('handleRenderVideo', () => {
|
|
1890
|
+
it('calls videoRenderService.startRender with node config', async () => {
|
|
1891
|
+
const mockVideoRenderService = {
|
|
1892
|
+
startRender: mock.fn(async () => ({
|
|
1893
|
+
id: 'render-123',
|
|
1894
|
+
status: 'queued',
|
|
1895
|
+
progress: 0,
|
|
1896
|
+
filePath: null,
|
|
1897
|
+
error: null,
|
|
1898
|
+
compositionId: 'my-comp',
|
|
1899
|
+
})),
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
const engine = new WorkflowEngine({
|
|
1903
|
+
db: createMockDb() as any,
|
|
1904
|
+
kanban: createMockKanban(),
|
|
1905
|
+
claudeService: createMockClaudeService(),
|
|
1906
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1907
|
+
videoRenderService: mockVideoRenderService,
|
|
1908
|
+
log: createMockLog(),
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
const node = {
|
|
1912
|
+
id: 'render',
|
|
1913
|
+
type: 'renderVideo',
|
|
1914
|
+
data: { compositionId: 'my-comp', resolution: '1280x720', fileOutputPrefix: 'output' },
|
|
1915
|
+
};
|
|
1916
|
+
const ctx = defaultContext({ projectId: 'proj_1' });
|
|
1917
|
+
const result = await (engine as any).handleRenderVideo(node, ctx);
|
|
1918
|
+
|
|
1919
|
+
assert.equal(result.outputLabel, null);
|
|
1920
|
+
assert.equal(result.outputData.renderId, 'render-123');
|
|
1921
|
+
assert.equal(result.outputData.status, 'queued');
|
|
1922
|
+
assert.equal(result.outputData.compositionId, 'my-comp');
|
|
1923
|
+
assert.equal(mockVideoRenderService.startRender.mock.callCount(), 1);
|
|
1924
|
+
|
|
1925
|
+
const callArgs = mockVideoRenderService.startRender.mock.calls[0].arguments[0];
|
|
1926
|
+
assert.equal(callArgs.compositionId, 'my-comp');
|
|
1927
|
+
assert.equal(callArgs.resolution, '1280x720');
|
|
1928
|
+
assert.equal(callArgs.fileOutputPrefix, 'output');
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
it('falls back to compositionName from context when compositionId is empty', async () => {
|
|
1932
|
+
const mockVideoRenderService = {
|
|
1933
|
+
startRender: mock.fn(async () => ({
|
|
1934
|
+
id: 'render-456',
|
|
1935
|
+
status: 'queued',
|
|
1936
|
+
progress: 0,
|
|
1937
|
+
filePath: null,
|
|
1938
|
+
error: null,
|
|
1939
|
+
compositionId: 'my-feature',
|
|
1940
|
+
})),
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
const engine = new WorkflowEngine({
|
|
1944
|
+
db: createMockDb() as any,
|
|
1945
|
+
kanban: createMockKanban(),
|
|
1946
|
+
claudeService: createMockClaudeService(),
|
|
1947
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1948
|
+
videoRenderService: mockVideoRenderService,
|
|
1949
|
+
log: createMockLog(),
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
const node = { id: 'render', type: 'renderVideo', data: {} };
|
|
1953
|
+
const ctx = defaultContext({ projectId: 'proj_1', compositionName: 'my-feature' });
|
|
1954
|
+
const result = await (engine as any).handleRenderVideo(node, ctx);
|
|
1955
|
+
|
|
1956
|
+
assert.equal(result.outputData.compositionId, 'my-feature');
|
|
1957
|
+
const callArgs = mockVideoRenderService.startRender.mock.calls[0].arguments[0];
|
|
1958
|
+
assert.equal(callArgs.compositionId, 'my-feature');
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
it('throws when videoRenderService is not configured', async () => {
|
|
1962
|
+
const engine = new WorkflowEngine({
|
|
1963
|
+
db: createMockDb() as any,
|
|
1964
|
+
kanban: createMockKanban(),
|
|
1965
|
+
claudeService: createMockClaudeService(),
|
|
1966
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1967
|
+
log: createMockLog(),
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
const node = { id: 'render', type: 'renderVideo', data: {} };
|
|
1971
|
+
await assert.rejects(
|
|
1972
|
+
() => (engine as any).handleRenderVideo(node, defaultContext()),
|
|
1973
|
+
{ message: /VideoRenderService not configured/ },
|
|
1974
|
+
);
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
it('throws when no compositionId is available', async () => {
|
|
1978
|
+
const mockVideoRenderService = {
|
|
1979
|
+
startRender: mock.fn(async () => ({})),
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
const engine = new WorkflowEngine({
|
|
1983
|
+
db: createMockDb() as any,
|
|
1984
|
+
kanban: createMockKanban(),
|
|
1985
|
+
claudeService: createMockClaudeService(),
|
|
1986
|
+
gitWorkflow: createMockGitWorkflow(),
|
|
1987
|
+
videoRenderService: mockVideoRenderService,
|
|
1988
|
+
log: createMockLog(),
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
const node = { id: 'render', type: 'renderVideo', data: {} };
|
|
1992
|
+
const ctx = defaultContext({ projectId: 'proj_1' });
|
|
1993
|
+
await assert.rejects(
|
|
1994
|
+
() => (engine as any).handleRenderVideo(node, ctx),
|
|
1995
|
+
{ message: /No compositionId configured/ },
|
|
1996
|
+
);
|
|
1997
|
+
});
|
|
1998
|
+
});
|
|
1753
1999
|
});
|
|
@@ -63,6 +63,22 @@ interface NodeHandlerResult {
|
|
|
63
63
|
outputData?: Record<string, unknown>;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
interface BundleServiceDep {
|
|
67
|
+
buildBundle: () => Promise<{ ready: boolean; building: boolean; bundlePath: string | null; error: string | null }>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface TtsServiceDep {
|
|
71
|
+
generate: (opts: { scriptPath: string; projectId: string; featureId: string; force?: boolean; voiceId?: string }) => Promise<{
|
|
72
|
+
processed: number; generated: number; skipped: number; errors: string[]; durations: Record<string, number>; audioBasePath: string;
|
|
73
|
+
}>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface VideoRenderServiceDep {
|
|
77
|
+
startRender: (request: {
|
|
78
|
+
compositionId: string; projectId: string; featureId: string; resolution?: string; aspectRatio?: string; fileOutputPrefix?: string;
|
|
79
|
+
}) => Promise<{ id: string; status: string; progress: number; filePath: string | null; error: string | null; compositionId: string }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
interface WorkflowEngineDeps {
|
|
67
83
|
db: ReturnType<typeof import('./db.js').getDb>;
|
|
68
84
|
kanban: {
|
|
@@ -85,6 +101,9 @@ interface WorkflowEngineDeps {
|
|
|
85
101
|
unstash: () => Promise<void>;
|
|
86
102
|
getDirtyFiles: () => Promise<string[]>;
|
|
87
103
|
};
|
|
104
|
+
bundleService?: BundleServiceDep;
|
|
105
|
+
ttsService?: TtsServiceDep;
|
|
106
|
+
videoRenderService?: VideoRenderServiceDep;
|
|
88
107
|
log: (tag: string, message: string) => void;
|
|
89
108
|
}
|
|
90
109
|
|
|
@@ -95,13 +114,19 @@ export class WorkflowEngine {
|
|
|
95
114
|
private kanban: WorkflowEngineDeps['kanban'];
|
|
96
115
|
private claudeService: WorkflowEngineDeps['claudeService'];
|
|
97
116
|
private gitWorkflow: WorkflowEngineDeps['gitWorkflow'];
|
|
117
|
+
private bundleService?: BundleServiceDep;
|
|
118
|
+
private ttsService?: TtsServiceDep;
|
|
119
|
+
private videoRenderService?: VideoRenderServiceDep;
|
|
98
120
|
private log: WorkflowEngineDeps['log'];
|
|
99
121
|
|
|
100
|
-
constructor({ db, kanban, claudeService, gitWorkflow, log }: WorkflowEngineDeps) {
|
|
122
|
+
constructor({ db, kanban, claudeService, gitWorkflow, bundleService, ttsService, videoRenderService, log }: WorkflowEngineDeps) {
|
|
101
123
|
this.db = db;
|
|
102
124
|
this.kanban = kanban;
|
|
103
125
|
this.claudeService = claudeService;
|
|
104
126
|
this.gitWorkflow = gitWorkflow;
|
|
127
|
+
this.bundleService = bundleService;
|
|
128
|
+
this.ttsService = ttsService;
|
|
129
|
+
this.videoRenderService = videoRenderService;
|
|
105
130
|
this.log = log;
|
|
106
131
|
}
|
|
107
132
|
|
|
@@ -159,7 +184,7 @@ export class WorkflowEngine {
|
|
|
159
184
|
// Load iteration comments from kanban notes for re-runs
|
|
160
185
|
const kanbanEntry = await this.kanban.getKanbanEntry(featureId);
|
|
161
186
|
if (kanbanEntry?.notes?.length) {
|
|
162
|
-
context.iterationComments = '## Previous Review Notes\n' + (kanbanEntry.notes as string
|
|
187
|
+
context.iterationComments = '## Previous Review Notes\n' + (kanbanEntry.notes as Array<{ text?: string }>).map((n) => `- ${typeof n === 'string' ? n : (n.text || '')}`).join('\n');
|
|
163
188
|
} else {
|
|
164
189
|
context.iterationComments = '';
|
|
165
190
|
}
|
|
@@ -598,6 +623,12 @@ export class WorkflowEngine {
|
|
|
598
623
|
return this.handleEnd(node, context, executionId);
|
|
599
624
|
case 'group':
|
|
600
625
|
return this.handleGroup(node, context, executionId);
|
|
626
|
+
case 'rebuildBundle':
|
|
627
|
+
return this.handleRebuildBundle(node, context);
|
|
628
|
+
case 'generateTTS':
|
|
629
|
+
return this.handleGenerateTTS(node, context);
|
|
630
|
+
case 'renderVideo':
|
|
631
|
+
return this.handleRenderVideo(node, context);
|
|
601
632
|
default:
|
|
602
633
|
throw new Error(`Unknown node type: ${node.type}`);
|
|
603
634
|
}
|
|
@@ -919,6 +950,131 @@ export class WorkflowEngine {
|
|
|
919
950
|
return { outputLabel: null };
|
|
920
951
|
};
|
|
921
952
|
|
|
953
|
+
/**
|
|
954
|
+
* RebuildBundle — triggers BundleService to compile the Remotion webpack bundle.
|
|
955
|
+
*/
|
|
956
|
+
private handleRebuildBundle = async (
|
|
957
|
+
_node: WorkflowNode,
|
|
958
|
+
_context: ExecutionContext,
|
|
959
|
+
): Promise<NodeHandlerResult> => {
|
|
960
|
+
if (!this.bundleService) {
|
|
961
|
+
throw new Error('BundleService not configured — cannot execute rebuildBundle node');
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
this.log('WORKFLOW', 'Building Remotion bundle...');
|
|
965
|
+
const result = await this.bundleService.buildBundle();
|
|
966
|
+
|
|
967
|
+
if (!result.ready) {
|
|
968
|
+
throw new Error(`Bundle build failed: ${result.error || 'unknown error'}`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
this.log('WORKFLOW', `Bundle built successfully at ${result.bundlePath}`);
|
|
972
|
+
return {
|
|
973
|
+
outputLabel: null,
|
|
974
|
+
outputData: { bundlePath: result.bundlePath, ready: result.ready },
|
|
975
|
+
};
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* GenerateTTS — runs TtsService to generate audio from a video script.
|
|
980
|
+
* Resolves scriptPath relative to the feature's worktree.
|
|
981
|
+
*/
|
|
982
|
+
private handleGenerateTTS = async (
|
|
983
|
+
node: WorkflowNode,
|
|
984
|
+
context: ExecutionContext,
|
|
985
|
+
): Promise<NodeHandlerResult> => {
|
|
986
|
+
if (!this.ttsService) {
|
|
987
|
+
throw new Error('TtsService not configured — cannot execute generateTTS node');
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const { scriptPath, force, voiceId } = node.data as {
|
|
991
|
+
scriptPath?: string;
|
|
992
|
+
force?: boolean;
|
|
993
|
+
voiceId?: string;
|
|
994
|
+
};
|
|
995
|
+
const { featureId, projectId, worktreePath } = context;
|
|
996
|
+
|
|
997
|
+
// Resolve script path relative to worktree
|
|
998
|
+
const resolvedScriptPath = scriptPath
|
|
999
|
+
? pathResolve(worktreePath as string, scriptPath)
|
|
1000
|
+
: pathResolve(worktreePath as string, 'script.md');
|
|
1001
|
+
|
|
1002
|
+
this.log('WORKFLOW', `Generating TTS for feature=${featureId} script=${resolvedScriptPath}`);
|
|
1003
|
+
|
|
1004
|
+
const result = await this.ttsService.generate({
|
|
1005
|
+
scriptPath: resolvedScriptPath,
|
|
1006
|
+
projectId: projectId as string,
|
|
1007
|
+
featureId: featureId as string,
|
|
1008
|
+
force: force || false,
|
|
1009
|
+
voiceId: voiceId || undefined,
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
if (result.errors.length > 0) {
|
|
1013
|
+
this.log('WORKFLOW', `TTS completed with errors: ${result.errors.join(', ')}`);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
this.log('WORKFLOW', `TTS complete: ${result.generated} generated, ${result.skipped} skipped`);
|
|
1017
|
+
return {
|
|
1018
|
+
outputLabel: null,
|
|
1019
|
+
outputData: {
|
|
1020
|
+
processed: result.processed,
|
|
1021
|
+
generated: result.generated,
|
|
1022
|
+
skipped: result.skipped,
|
|
1023
|
+
errors: result.errors,
|
|
1024
|
+
audioBasePath: result.audioBasePath,
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* RenderVideo — triggers VideoRenderService to render a composition to MP4.
|
|
1031
|
+
* Waits for the render to complete by polling status.
|
|
1032
|
+
*/
|
|
1033
|
+
private handleRenderVideo = async (
|
|
1034
|
+
node: WorkflowNode,
|
|
1035
|
+
context: ExecutionContext,
|
|
1036
|
+
): Promise<NodeHandlerResult> => {
|
|
1037
|
+
if (!this.videoRenderService) {
|
|
1038
|
+
throw new Error('VideoRenderService not configured — cannot execute renderVideo node');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const { compositionId, resolution, aspectRatio, fileOutputPrefix } = node.data as {
|
|
1042
|
+
compositionId?: string;
|
|
1043
|
+
resolution?: string;
|
|
1044
|
+
aspectRatio?: string;
|
|
1045
|
+
fileOutputPrefix?: string;
|
|
1046
|
+
};
|
|
1047
|
+
const { featureId, projectId, compositionName } = context;
|
|
1048
|
+
|
|
1049
|
+
// Use compositionId from node data, or fall back to context compositionName
|
|
1050
|
+
const resolvedCompositionId = compositionId || (compositionName as string) || '';
|
|
1051
|
+
if (!resolvedCompositionId) {
|
|
1052
|
+
throw new Error('No compositionId configured on renderVideo node and no compositionName in context');
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
this.log('WORKFLOW', `Starting render: composition=${resolvedCompositionId} resolution=${resolution || '1920x1080'}`);
|
|
1056
|
+
|
|
1057
|
+
const renderStatus = await this.videoRenderService.startRender({
|
|
1058
|
+
compositionId: resolvedCompositionId,
|
|
1059
|
+
projectId: projectId as string,
|
|
1060
|
+
featureId: featureId as string,
|
|
1061
|
+
resolution,
|
|
1062
|
+
aspectRatio,
|
|
1063
|
+
fileOutputPrefix,
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
this.log('WORKFLOW', `Render started: id=${renderStatus.id} status=${renderStatus.status}`);
|
|
1067
|
+
return {
|
|
1068
|
+
outputLabel: null,
|
|
1069
|
+
outputData: {
|
|
1070
|
+
renderId: renderStatus.id,
|
|
1071
|
+
status: renderStatus.status,
|
|
1072
|
+
compositionId: resolvedCompositionId,
|
|
1073
|
+
filePath: renderStatus.filePath,
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
};
|
|
1077
|
+
|
|
922
1078
|
// ── Merge & Cleanup Helpers ─────────────────────────────────────────
|
|
923
1079
|
|
|
924
1080
|
/**
|
|
@@ -19,6 +19,7 @@ describe('VideoRenderService', () => {
|
|
|
19
19
|
let routeContent: string;
|
|
20
20
|
let schemaContent: string;
|
|
21
21
|
let serverContent: string;
|
|
22
|
+
let initContent: string;
|
|
22
23
|
let dockerfileContent: string;
|
|
23
24
|
|
|
24
25
|
it('loads source files', async () => {
|
|
@@ -26,6 +27,7 @@ describe('VideoRenderService', () => {
|
|
|
26
27
|
routeContent = await readFile(join(BACKEND_SRC, 'routes', 'video.ts'), 'utf-8');
|
|
27
28
|
schemaContent = await readFile(join(SHARED_DB, 'schema.ts'), 'utf-8');
|
|
28
29
|
serverContent = await readFile(join(BACKEND_SRC, 'server.ts'), 'utf-8');
|
|
30
|
+
initContent = await readFile(join(BACKEND_SRC, 'services', 'init.ts'), 'utf-8');
|
|
29
31
|
dockerfileContent = await readFile(join(SOURCE_ROOT, '..', 'Dockerfile'), 'utf-8');
|
|
30
32
|
assert.ok(serviceContent.length > 0);
|
|
31
33
|
assert.ok(routeContent.length > 0);
|
|
@@ -127,12 +129,12 @@ describe('VideoRenderService', () => {
|
|
|
127
129
|
});
|
|
128
130
|
|
|
129
131
|
describe('Server integration', () => {
|
|
130
|
-
it('imports VideoRenderService', () => {
|
|
131
|
-
assert.ok(
|
|
132
|
+
it('imports VideoRenderService in init.ts', () => {
|
|
133
|
+
assert.ok(initContent.includes("import { VideoRenderService }"), 'init.ts should import VideoRenderService');
|
|
132
134
|
});
|
|
133
135
|
|
|
134
|
-
it('instantiates VideoRenderService with DI', () => {
|
|
135
|
-
assert.ok(
|
|
136
|
+
it('instantiates VideoRenderService with DI in init.ts', () => {
|
|
137
|
+
assert.ok(initContent.includes('new VideoRenderService('), 'init.ts should instantiate VideoRenderService');
|
|
136
138
|
});
|
|
137
139
|
|
|
138
140
|
it('passes videoRenderService to video routes', () => {
|