@assistkick/create 1.7.0 → 1.8.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.
Files changed (200) hide show
  1. package/dist/bin/create.js +0 -0
  2. package/package.json +9 -7
  3. package/templates/assistkick-product-system/.env.example +1 -0
  4. package/templates/assistkick-product-system/local.db +0 -0
  5. package/templates/assistkick-product-system/package.json +4 -2
  6. package/templates/assistkick-product-system/packages/backend/package.json +2 -0
  7. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +165 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +358 -0
  9. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +356 -0
  10. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +96 -1
  11. package/templates/assistkick-product-system/packages/backend/src/routes/graph.ts +1 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +43 -4
  13. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +200 -84
  14. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +6 -3
  15. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +53 -17
  16. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +218 -0
  17. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +119 -0
  18. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +154 -0
  19. package/templates/assistkick-product-system/packages/backend/src/server.ts +81 -9
  20. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +489 -0
  21. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +416 -0
  22. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.test.ts +189 -0
  23. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.ts +182 -0
  24. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +28 -78
  25. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +16 -0
  26. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +73 -2
  27. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +4 -4
  28. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +87 -11
  29. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +210 -69
  30. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +210 -215
  31. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +162 -0
  32. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +148 -0
  33. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +11 -5
  34. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.test.ts +64 -0
  35. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +134 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.test.ts +256 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +258 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/workflow_group_service.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.test.ts +275 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +222 -0
  41. package/templates/assistkick-product-system/packages/frontend/package-lock.json +3455 -0
  42. package/templates/assistkick-product-system/packages/frontend/package.json +6 -0
  43. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +8 -0
  44. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +456 -16
  45. package/templates/assistkick-product-system/packages/frontend/src/api/client_files.test.ts +172 -0
  46. package/templates/assistkick-product-system/packages/frontend/src/api/client_video.test.ts +238 -0
  47. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +307 -0
  48. package/templates/assistkick-product-system/packages/frontend/src/components/CoherenceView.tsx +82 -66
  49. package/templates/assistkick-product-system/packages/frontend/src/components/CompositionPlaceholder.tsx +97 -0
  50. package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +20 -0
  51. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +57 -0
  52. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +313 -0
  53. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeContextMenu.tsx +61 -0
  54. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +73 -0
  55. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +404 -0
  56. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +187 -56
  57. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +71 -73
  58. package/templates/assistkick-product-system/packages/frontend/src/components/GraphSettings.tsx +8 -8
  59. package/templates/assistkick-product-system/packages/frontend/src/components/GraphView.tsx +1 -1
  60. package/templates/assistkick-product-system/packages/frontend/src/components/InviteUserDialog.tsx +15 -11
  61. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +202 -171
  62. package/templates/assistkick-product-system/packages/frontend/src/components/LoginPage.tsx +14 -14
  63. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +54 -33
  64. package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +32 -49
  65. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +43 -48
  66. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +121 -52
  67. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +20 -14
  68. package/templates/assistkick-product-system/packages/frontend/src/components/UsersView.tsx +52 -52
  69. package/templates/assistkick-product-system/packages/frontend/src/components/VideoGallery.tsx +313 -0
  70. package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx +250 -0
  71. package/templates/assistkick-product-system/packages/frontend/src/components/WorkflowsView.tsx +474 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/ds/AccentBorderList.tsx +53 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Button.tsx +87 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonGroup.tsx +29 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonShowcase.tsx +221 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CardGlass.tsx +141 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CompletionRing.tsx +30 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ContentCard.tsx +34 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ds/IconButton.tsx +74 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +103 -87
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +9 -188
  82. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Kbd.tsx +11 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KindBadge.tsx +21 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +81 -37
  85. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SidePanelShowcase.tsx +370 -0
  86. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SideSheet.tsx +64 -0
  87. package/templates/assistkick-product-system/packages/frontend/src/components/ds/StatusDot.tsx +18 -0
  88. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCardPositionNode.tsx +36 -0
  89. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCycleCountNode.tsx +60 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/EndNode.tsx +42 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +189 -0
  92. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +123 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RunAgentNode.tsx +51 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/SetCardMetadataNode.tsx +53 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/StartNode.tsx +18 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/TransitionCardNode.tsx +59 -0
  97. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +335 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +634 -0
  99. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/autoLayout.ts +103 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/edgeColors.ts +35 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +208 -0
  102. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.test.ts +119 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +107 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +13 -11
  105. package/templates/assistkick-product-system/packages/frontend/src/hooks/useAutoSave.ts +75 -0
  106. package/templates/assistkick-product-system/packages/frontend/src/hooks/useToast.tsx +16 -3
  107. package/templates/assistkick-product-system/packages/frontend/src/pages/accept_invitation_page.tsx +30 -27
  108. package/templates/assistkick-product-system/packages/frontend/src/pages/forgot_password_page.tsx +18 -15
  109. package/templates/assistkick-product-system/packages/frontend/src/pages/register_page.tsx +21 -18
  110. package/templates/assistkick-product-system/packages/frontend/src/pages/reset_password_page.tsx +28 -25
  111. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +6 -0
  112. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +1 -1
  113. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +2 -2
  114. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +13 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -2
  116. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +13 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +6 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +6 -3
  119. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +4 -4
  120. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +275 -3535
  121. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.test.ts +167 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.ts +101 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.test.ts +42 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.ts +17 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.test.ts +145 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.ts +42 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.test.ts +4 -10
  128. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.ts +19 -1
  129. package/templates/assistkick-product-system/packages/frontend/vite.config.ts +5 -0
  130. package/templates/assistkick-product-system/packages/shared/db/local.db +0 -0
  131. package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +9 -0
  132. package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +692 -0
  133. package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +9 -0
  134. package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +39 -0
  135. package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +1 -0
  136. package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +8 -0
  137. package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +9 -0
  138. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +3 -0
  139. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +3 -0
  140. package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +15 -0
  141. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +921 -0
  142. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +1042 -0
  143. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +1101 -0
  144. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +1336 -0
  145. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +1275 -0
  146. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +1327 -0
  147. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +1393 -0
  148. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +1436 -0
  149. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +1538 -0
  150. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  151. package/templates/assistkick-product-system/packages/shared/db/schema.ts +113 -0
  152. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +32 -7
  153. package/templates/assistkick-product-system/packages/shared/lib/constants.ts +9 -0
  154. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +12 -4
  155. package/templates/assistkick-product-system/packages/shared/lib/graph.ts +5 -0
  156. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +1753 -0
  157. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +1281 -0
  158. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +211 -0
  159. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +43 -0
  160. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +13 -2
  161. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +1 -1
  162. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.test.ts +226 -0
  163. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.ts +251 -0
  164. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  165. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.test.ts +10 -0
  166. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.ts +6 -0
  167. package/templates/assistkick-product-system/packages/video/Root.tsx +85 -0
  168. package/templates/assistkick-product-system/packages/video/components/email_scene.tsx +231 -0
  169. package/templates/assistkick-product-system/packages/video/components/outro_scene.tsx +153 -0
  170. package/templates/assistkick-product-system/packages/video/components/part_divider.tsx +90 -0
  171. package/templates/assistkick-product-system/packages/video/components/scene.tsx +226 -0
  172. package/templates/assistkick-product-system/packages/video/components/theme.ts +22 -0
  173. package/templates/assistkick-product-system/packages/video/components/title_scene.tsx +169 -0
  174. package/templates/assistkick-product-system/packages/video/components/video_split_layout.tsx +84 -0
  175. package/templates/assistkick-product-system/packages/video/compositions/.gitkeep +0 -0
  176. package/templates/assistkick-product-system/packages/video/index.ts +4 -0
  177. package/templates/assistkick-product-system/packages/video/package.json +28 -0
  178. package/templates/assistkick-product-system/packages/video/remotion.config.ts +11 -0
  179. package/templates/assistkick-product-system/packages/video/scripts/process_script.test.ts +326 -0
  180. package/templates/assistkick-product-system/packages/video/scripts/process_script.ts +630 -0
  181. package/templates/assistkick-product-system/packages/video/style.css +1 -0
  182. package/templates/assistkick-product-system/packages/video/tsconfig.json +18 -0
  183. package/templates/assistkick-product-system/tests/graph_legend.test.ts +2 -1
  184. package/templates/assistkick-product-system/tests/video_render_service.test.ts +179 -0
  185. package/templates/assistkick-product-system/tests/web_terminal.test.ts +219 -455
  186. package/templates/assistkick-product-system/tests/workflow_integration.test.ts +341 -0
  187. package/templates/skills/assistkick-developer/SKILL.md +3 -0
  188. package/templates/skills/assistkick-developer/references/react_development_guidelines.md +225 -0
  189. package/templates/skills/product-system/graph.json +1890 -0
  190. package/templates/skills/product-system/kanban.json +304 -0
  191. package/templates/skills/product-system/nodes/comp_001.md +56 -0
  192. package/templates/skills/product-system/nodes/comp_002.md +57 -0
  193. package/templates/skills/product-system/nodes/data_001.md +51 -0
  194. package/templates/skills/product-system/nodes/data_002.md +40 -0
  195. package/templates/skills/product-system/nodes/data_004.md +38 -0
  196. package/templates/skills/product-system/nodes/dec_001.md +34 -0
  197. package/templates/skills/product-system/nodes/dec_016.md +32 -0
  198. package/templates/skills/product-system/nodes/feat_008.md +30 -0
  199. package/templates/skills/video-composition-agent/SKILL.md +232 -0
  200. package/templates/skills/video-script-writer/SKILL.md +136 -0
@@ -0,0 +1,630 @@
1
+ /**
2
+ * Processes a video script markdown file:
3
+ * - Parses :::narration and :::screencapture directives
4
+ * - Generates TTS audio via ElevenLabs for missing or changed narrations
5
+ * - Measures audio and video durations
6
+ * - Writes durations.json and hashes.json to the media directory
7
+ * - Reports missing video files
8
+ *
9
+ * Text changes are detected via SHA-256 hashes stored in hashes.json.
10
+ * When narration text is edited, the audio is regenerated on the next run.
11
+ *
12
+ * Usage:
13
+ * tsx scripts/process_script.ts <script.md> --project-id <id> --feature-id <id> [options]
14
+ *
15
+ * Options:
16
+ * --project-id ID Project ID (required — resolves media dir)
17
+ * --feature-id ID Feature ID (required — scopes media within project)
18
+ * --media-base DIR Override media base dir (default: /data/workspaces/<projectId>/media)
19
+ * --force Regenerate all audio files, even if they exist
20
+ * --voice NAME ElevenLabs voice name (default: George)
21
+ * --voice-id ID ElevenLabs voice ID (overrides --voice)
22
+ * --dry-run Show what would be generated without calling the API
23
+ * --json Output machine-readable JSON summary (for API integration)
24
+ * --list-voices List available ElevenLabs voices
25
+ */
26
+
27
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
28
+ import { createHash } from "crypto";
29
+ import { join, resolve } from "path";
30
+ import https from "https";
31
+
32
+ // ── ElevenLabs config ────────────────────────────────────────────────────────
33
+
34
+ const API_BASE = "api.elevenlabs.io";
35
+ const DEFAULT_MODEL = "eleven_v3";
36
+ const DEFAULT_OUTPUT_FORMAT = "mp3_44100_128";
37
+ const DEFAULT_VOICE_ID = "tnSpp4vdxKPjI9w0GnoV"; // George
38
+
39
+ // ── Types ────────────────────────────────────────────────────────────────────
40
+
41
+ export type Narration = { id: string; src: string; text: string };
42
+ export type ScreenCapture = { id: string; src: string; description: string };
43
+
44
+ export type TtsResult = {
45
+ processed: number;
46
+ generated: number;
47
+ skipped: number;
48
+ errors: string[];
49
+ durations: Record<string, number>;
50
+ audioBasePath: string;
51
+ };
52
+
53
+ type CliArgs = {
54
+ script: string | null;
55
+ projectId: string | null;
56
+ featureId: string | null;
57
+ mediaBase: string | null;
58
+ force: boolean;
59
+ voice: string | null;
60
+ voiceId: string | null;
61
+ dryRun: boolean;
62
+ json: boolean;
63
+ listVoices: boolean;
64
+ help: boolean;
65
+ };
66
+
67
+ export type ProcessScriptOptions = {
68
+ scriptPath: string;
69
+ projectId: string;
70
+ featureId: string;
71
+ mediaBase?: string;
72
+ force?: boolean;
73
+ voiceId?: string;
74
+ voice?: string;
75
+ dryRun?: boolean;
76
+ };
77
+
78
+ // ── Hashing ──────────────────────────────────────────────────────────────────
79
+
80
+ export function hashText(text: string): string {
81
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
82
+ }
83
+
84
+ function loadHashes(hashesFile: string): Record<string, string> {
85
+ try {
86
+ return JSON.parse(readFileSync(hashesFile, "utf-8"));
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ // ── Parse :::narration fenced directives ─────────────────────────────────────
93
+
94
+ export function parseNarrations(markdown: string): Narration[] {
95
+ const regex = /:::narration\s+(\S+)\s*\n([\s\S]*?):::/g;
96
+ const narrations: Narration[] = [];
97
+ let match;
98
+ while ((match = regex.exec(markdown)) !== null) {
99
+ const id = match[1];
100
+ narrations.push({
101
+ id,
102
+ src: `audio/${id}.mp3`,
103
+ text: match[2].trim().replace(/\n+/g, " "),
104
+ });
105
+ }
106
+ return narrations;
107
+ }
108
+
109
+ // ── Parse :::screencapture fenced directives ─────────────────────────────────
110
+
111
+ export function parseScreenCaptures(markdown: string): ScreenCapture[] {
112
+ const regex = /:::screencapture\s+(\S+)\s*\n([\s\S]*?):::/g;
113
+ const captures: ScreenCapture[] = [];
114
+ let match;
115
+ while ((match = regex.exec(markdown)) !== null) {
116
+ const id = match[1];
117
+ captures.push({
118
+ id,
119
+ src: `video/${id}.mp4`,
120
+ description: match[2].trim().replace(/\n+/g, " "),
121
+ });
122
+ }
123
+ return captures;
124
+ }
125
+
126
+ // ── MP3 duration measurement (pure JS — no ffprobe) ──────────────────────────
127
+
128
+ const MP3_BITRATES: Record<string, number[]> = {
129
+ "11": [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448],
130
+ "12": [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384],
131
+ "13": [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
132
+ "21": [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256],
133
+ "22": [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
134
+ "23": [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
135
+ };
136
+
137
+ const MP3_SAMPLE_RATES: Record<string, number[]> = {
138
+ "1": [44100, 48000, 32000],
139
+ "2": [22050, 24000, 16000],
140
+ "25": [11025, 12000, 8000],
141
+ };
142
+
143
+ export function getAudioDuration(filePath: string): number | null {
144
+ try {
145
+ const buf = readFileSync(filePath);
146
+ const fileSize = buf.length;
147
+
148
+ // Skip ID3v2 tag if present
149
+ let offset = 0;
150
+ if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) {
151
+ const tagSize =
152
+ ((buf[6] & 0x7f) << 21) |
153
+ ((buf[7] & 0x7f) << 14) |
154
+ ((buf[8] & 0x7f) << 7) |
155
+ (buf[9] & 0x7f);
156
+ offset = 10 + tagSize;
157
+ }
158
+
159
+ // Find first valid MP3 frame
160
+ while (offset < fileSize - 4) {
161
+ if (buf[offset] === 0xff && (buf[offset + 1] & 0xe0) === 0xe0) {
162
+ const header =
163
+ (buf[offset] << 24) |
164
+ (buf[offset + 1] << 16) |
165
+ (buf[offset + 2] << 8) |
166
+ buf[offset + 3];
167
+
168
+ const versionBits = (header >> 19) & 0x03;
169
+ const layerBits = (header >> 17) & 0x03;
170
+ const bitrateIdx = (header >> 12) & 0x0f;
171
+ const sampleIdx = (header >> 10) & 0x03;
172
+
173
+ if (
174
+ versionBits === 1 ||
175
+ layerBits === 0 ||
176
+ bitrateIdx === 0 ||
177
+ bitrateIdx === 15 ||
178
+ sampleIdx === 3
179
+ ) {
180
+ offset++;
181
+ continue;
182
+ }
183
+
184
+ const versionKey =
185
+ versionBits === 3 ? "1" : versionBits === 2 ? "2" : "25";
186
+ const layerKey = 4 - layerBits;
187
+ const brKey = `${versionBits === 3 ? 1 : 2}${layerKey}`;
188
+ const bitrate = (MP3_BITRATES[brKey]?.[bitrateIdx] ?? 0) * 1000;
189
+ const sampleRate = MP3_SAMPLE_RATES[versionKey]?.[sampleIdx] ?? 0;
190
+
191
+ if (bitrate > 0 && sampleRate > 0) {
192
+ const audioBytes = fileSize - offset;
193
+ const duration = (audioBytes * 8) / bitrate;
194
+ return Math.round(duration * 100) / 100;
195
+ }
196
+ }
197
+ offset++;
198
+ }
199
+ return null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ // ── MP4 duration measurement (pure JS — reads mvhd atom) ─────────────────────
206
+
207
+ export function getMp4Duration(filePath: string): number | null {
208
+ try {
209
+ const buf = readFileSync(filePath);
210
+ const len = buf.length;
211
+ let offset = 0;
212
+
213
+ while (offset < len - 8) {
214
+ const size = buf.readUInt32BE(offset);
215
+ const type = buf.toString("ascii", offset + 4, offset + 8);
216
+
217
+ if (size < 8 || offset + size > len) break;
218
+
219
+ // Recurse into container atoms
220
+ if (type === "moov" || type === "trak" || type === "mdia") {
221
+ offset += 8;
222
+ continue;
223
+ }
224
+
225
+ if (type === "mvhd") {
226
+ const version = buf[offset + 8];
227
+ let timescale: number;
228
+ let duration: number;
229
+
230
+ if (version === 0) {
231
+ timescale = buf.readUInt32BE(offset + 20);
232
+ duration = buf.readUInt32BE(offset + 24);
233
+ } else {
234
+ timescale = buf.readUInt32BE(offset + 28);
235
+ const high = buf.readUInt32BE(offset + 32);
236
+ const low = buf.readUInt32BE(offset + 36);
237
+ duration = high * 0x100000000 + low;
238
+ }
239
+
240
+ if (timescale > 0) {
241
+ return Math.round((duration / timescale) * 100) / 100;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ offset += size;
247
+ }
248
+ return null;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ // ── ElevenLabs API ───────────────────────────────────────────────────────────
255
+
256
+ function loadApiKey(): string {
257
+ const key = process.env.ELEVENLABS_API_KEY;
258
+ if (key) return key;
259
+
260
+ console.error("Error: ELEVENLABS_API_KEY environment variable not set.");
261
+ console.error("Set it with: export ELEVENLABS_API_KEY=your_key_here");
262
+ process.exit(1);
263
+ }
264
+
265
+ function apiRequest(
266
+ path: string,
267
+ apiKey: string,
268
+ method = "GET",
269
+ data: Record<string, unknown> | null = null,
270
+ ): Promise<Buffer> {
271
+ return new Promise((resolve, reject) => {
272
+ const headers: Record<string, string> = { "xi-api-key": apiKey };
273
+ if (data) headers["Content-Type"] = "application/json";
274
+
275
+ const req = https.request(
276
+ { hostname: API_BASE, path: `/v1${path}`, method, headers },
277
+ (res) => {
278
+ const chunks: Buffer[] = [];
279
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
280
+ res.on("end", () => {
281
+ const body = Buffer.concat(chunks);
282
+ if (res.statusCode && res.statusCode >= 400) {
283
+ reject(
284
+ new Error(`API error ${res.statusCode}: ${body.toString()}`),
285
+ );
286
+ return;
287
+ }
288
+ resolve(body);
289
+ });
290
+ },
291
+ );
292
+ req.on("error", reject);
293
+ if (data) req.write(JSON.stringify(data));
294
+ req.end();
295
+ });
296
+ }
297
+
298
+ async function listVoices(apiKey: string) {
299
+ const body = await apiRequest("/voices", apiKey);
300
+ const data = JSON.parse(body.toString());
301
+ console.log("Name".padEnd(30) + "Voice ID".padEnd(30) + "Category");
302
+ console.log("-".repeat(80));
303
+ for (const voice of data.voices ?? []) {
304
+ console.log(
305
+ (voice.name ?? "").padEnd(30) +
306
+ (voice.voice_id ?? "").padEnd(30) +
307
+ (voice.category ?? ""),
308
+ );
309
+ }
310
+ }
311
+
312
+ async function resolveVoice(
313
+ voiceName: string,
314
+ apiKey: string,
315
+ ): Promise<string> {
316
+ const body = await apiRequest("/voices", apiKey);
317
+ const data = JSON.parse(body.toString());
318
+ for (const voice of data.voices ?? []) {
319
+ if ((voice.name ?? "").toLowerCase() === voiceName.toLowerCase()) {
320
+ return voice.voice_id;
321
+ }
322
+ }
323
+ throw new Error(`Voice '${voiceName}' not found. Use --list-voices to see available voices.`);
324
+ }
325
+
326
+ async function generateTTS(
327
+ text: string,
328
+ voiceId: string,
329
+ apiKey: string,
330
+ outputPath: string,
331
+ ) {
332
+ const { dirname: dirName } = await import("path");
333
+ const audio = await apiRequest(
334
+ `/text-to-speech/${voiceId}?output_format=${DEFAULT_OUTPUT_FORMAT}`,
335
+ apiKey,
336
+ "POST",
337
+ { text, model_id: DEFAULT_MODEL },
338
+ );
339
+ mkdirSync(dirName(outputPath), { recursive: true });
340
+ writeFileSync(outputPath, audio);
341
+ }
342
+
343
+ // ── Core processing function (used by both CLI and API) ─────────────────────
344
+
345
+ export async function processScript(opts: ProcessScriptOptions): Promise<TtsResult> {
346
+ const {
347
+ scriptPath,
348
+ projectId,
349
+ featureId,
350
+ mediaBase,
351
+ force = false,
352
+ voiceId: voiceIdOpt,
353
+ voice,
354
+ dryRun = false,
355
+ } = opts;
356
+
357
+ const resolvedPath = resolve(scriptPath);
358
+ if (!existsSync(resolvedPath)) {
359
+ throw new Error(`Script file not found: ${resolvedPath}`);
360
+ }
361
+
362
+ const mediaDir = mediaBase
363
+ ? join(mediaBase, featureId)
364
+ : join("/data/workspaces", projectId, "media", featureId);
365
+ const audioDir = join(mediaDir, "audio");
366
+ const videoDir = join(mediaDir, "video");
367
+ const durationsFile = join(audioDir, "durations.json");
368
+ const videoDurationsFile = join(mediaDir, "video-durations.json");
369
+ const hashesFile = join(audioDir, "hashes.json");
370
+
371
+ const markdown = readFileSync(resolvedPath, "utf-8");
372
+ const narrations = parseNarrations(markdown);
373
+ const screenCaptures = parseScreenCaptures(markdown);
374
+
375
+ if (narrations.length === 0) {
376
+ throw new Error("No :::narration directives found in the script.");
377
+ }
378
+
379
+ // Check for missing video files and measure video durations
380
+ const videoDurations: Record<string, number> = {};
381
+ if (screenCaptures.length > 0) {
382
+ for (const c of screenCaptures) {
383
+ const videoPath = join(videoDir, `${c.id}.mp4`);
384
+ if (!existsSync(videoPath)) continue;
385
+ const dur = getMp4Duration(videoPath);
386
+ if (dur !== null) {
387
+ videoDurations[c.id] = dur;
388
+ }
389
+ }
390
+ }
391
+
392
+ mkdirSync(audioDir, { recursive: true });
393
+
394
+ const savedHashes = loadHashes(hashesFile);
395
+ const newHashes: Record<string, string> = {};
396
+ const errors: string[] = [];
397
+
398
+ const toGenerate: Narration[] = [];
399
+ let skipped = 0;
400
+ for (const n of narrations) {
401
+ const audioPath = join(audioDir, `${n.id}.mp3`);
402
+ const exists = existsSync(audioPath);
403
+ const currentHash = hashText(n.text);
404
+ newHashes[n.id] = currentHash;
405
+ const hashChanged = savedHashes[n.id] !== currentHash;
406
+
407
+ if (!exists || force || hashChanged) {
408
+ toGenerate.push(n);
409
+ } else {
410
+ skipped++;
411
+ }
412
+ }
413
+
414
+ let generated = 0;
415
+ if (toGenerate.length > 0 && !dryRun) {
416
+ const apiKey = process.env.ELEVENLABS_API_KEY;
417
+ if (!apiKey) {
418
+ throw new Error("ELEVENLABS_API_KEY environment variable not set.");
419
+ }
420
+
421
+ let finalVoiceId = DEFAULT_VOICE_ID;
422
+ if (voiceIdOpt) {
423
+ finalVoiceId = voiceIdOpt;
424
+ } else if (voice) {
425
+ finalVoiceId = await resolveVoice(voice, apiKey);
426
+ }
427
+
428
+ for (const n of toGenerate) {
429
+ const audioPath = join(audioDir, `${n.id}.mp3`);
430
+ try {
431
+ await generateTTS(n.text, finalVoiceId, apiKey, audioPath);
432
+ generated++;
433
+ } catch (err) {
434
+ errors.push(`${n.id}: ${(err as Error).message}`);
435
+ }
436
+ }
437
+ }
438
+
439
+ // Save text hashes
440
+ writeFileSync(hashesFile, JSON.stringify(newHashes, null, 2));
441
+
442
+ // Compute audio durations
443
+ const durations: Record<string, number> = {};
444
+ for (const n of narrations) {
445
+ const audioPath = join(audioDir, `${n.id}.mp3`);
446
+ const dur = getAudioDuration(audioPath);
447
+ if (dur !== null) {
448
+ durations[n.id] = dur;
449
+ }
450
+ }
451
+
452
+ writeFileSync(durationsFile, JSON.stringify(durations, null, 2));
453
+
454
+ // Write video durations
455
+ if (Object.keys(videoDurations).length > 0) {
456
+ writeFileSync(videoDurationsFile, JSON.stringify(videoDurations, null, 2));
457
+ }
458
+
459
+ return {
460
+ processed: narrations.length,
461
+ generated,
462
+ skipped,
463
+ errors,
464
+ durations,
465
+ audioBasePath: audioDir,
466
+ };
467
+ }
468
+
469
+ // ── CLI ──────────────────────────────────────────────────────────────────────
470
+
471
+ function parseArgs(argv: string[]): CliArgs {
472
+ const args: CliArgs = {
473
+ script: null,
474
+ projectId: null,
475
+ featureId: null,
476
+ mediaBase: null,
477
+ force: false,
478
+ voice: null,
479
+ voiceId: null,
480
+ dryRun: false,
481
+ json: false,
482
+ listVoices: false,
483
+ help: false,
484
+ };
485
+ let i = 0;
486
+ while (i < argv.length) {
487
+ const arg = argv[i];
488
+ switch (arg) {
489
+ case "--project-id":
490
+ args.projectId = argv[++i];
491
+ break;
492
+ case "--feature-id":
493
+ args.featureId = argv[++i];
494
+ break;
495
+ case "--media-base":
496
+ args.mediaBase = argv[++i];
497
+ break;
498
+ case "--force":
499
+ args.force = true;
500
+ break;
501
+ case "--voice":
502
+ args.voice = argv[++i];
503
+ break;
504
+ case "--voice-id":
505
+ args.voiceId = argv[++i];
506
+ break;
507
+ case "--dry-run":
508
+ args.dryRun = true;
509
+ break;
510
+ case "--json":
511
+ args.json = true;
512
+ break;
513
+ case "--list-voices":
514
+ args.listVoices = true;
515
+ break;
516
+ case "-h":
517
+ case "--help":
518
+ args.help = true;
519
+ break;
520
+ default:
521
+ if (!arg.startsWith("-") && !args.script) {
522
+ args.script = arg;
523
+ }
524
+ break;
525
+ }
526
+ i++;
527
+ }
528
+ return args;
529
+ }
530
+
531
+ function printHelp() {
532
+ console.log(`Usage: tsx scripts/process_script.ts <script.md> --project-id <id> --feature-id <id> [options]
533
+
534
+ Processes a video script: generates TTS audio, measures audio and video
535
+ durations, checks for missing video files, and writes duration JSON files.
536
+
537
+ Arguments:
538
+ script.md Path to script markdown file
539
+
540
+ Required:
541
+ --project-id ID Project ID (resolves media directory)
542
+ --feature-id ID Feature ID (scopes media within project)
543
+
544
+ Options:
545
+ --media-base DIR Override media base dir (default: /data/workspaces/<projectId>/media)
546
+ --force Regenerate all audio, even if files exist
547
+ --voice NAME ElevenLabs voice name (default: George)
548
+ --voice-id ID ElevenLabs voice ID (overrides --voice)
549
+ --dry-run Show what would be generated without calling the API
550
+ --json Output machine-readable JSON summary
551
+ --list-voices List available ElevenLabs voices
552
+ -h, --help Show this help`);
553
+ }
554
+
555
+ // ── Main ─────────────────────────────────────────────────────────────────────
556
+
557
+ async function main() {
558
+ const args = parseArgs(process.argv.slice(2));
559
+
560
+ if (args.help) {
561
+ printHelp();
562
+ return;
563
+ }
564
+
565
+ if (args.listVoices) {
566
+ const apiKey = loadApiKey();
567
+ await listVoices(apiKey);
568
+ return;
569
+ }
570
+
571
+ if (!args.script) {
572
+ console.error("Error: script path is required.");
573
+ printHelp();
574
+ process.exit(1);
575
+ }
576
+
577
+ if (!args.projectId || !args.featureId) {
578
+ console.error("Error: --project-id and --feature-id are required.");
579
+ process.exit(1);
580
+ }
581
+
582
+ try {
583
+ const result = await processScript({
584
+ scriptPath: args.script,
585
+ projectId: args.projectId,
586
+ featureId: args.featureId,
587
+ mediaBase: args.mediaBase ?? undefined,
588
+ force: args.force,
589
+ voiceId: args.voiceId ?? undefined,
590
+ voice: args.voice ?? undefined,
591
+ dryRun: args.dryRun,
592
+ });
593
+
594
+ if (args.json) {
595
+ console.log(JSON.stringify(result));
596
+ } else {
597
+ console.log(`Processed: ${result.processed} narration(s)`);
598
+ console.log(`Generated: ${result.generated} audio file(s)`);
599
+ console.log(`Skipped: ${result.skipped} (up to date)`);
600
+ if (result.errors.length > 0) {
601
+ console.log(`Errors: ${result.errors.length}`);
602
+ for (const err of result.errors) {
603
+ console.log(` - ${err}`);
604
+ }
605
+ }
606
+ console.log(`\nAudio durations:`);
607
+ for (const [id, dur] of Object.entries(result.durations)) {
608
+ console.log(` ${id}: ${dur.toFixed(2)}s`);
609
+ }
610
+ console.log(`\nAudio base path: ${result.audioBasePath}`);
611
+ }
612
+ } catch (err) {
613
+ if (args.json) {
614
+ console.log(JSON.stringify({ error: (err as Error).message }));
615
+ process.exit(1);
616
+ } else {
617
+ console.error(`Error: ${(err as Error).message}`);
618
+ process.exit(1);
619
+ }
620
+ }
621
+ }
622
+
623
+ // Only run when executed directly (not when imported for testing)
624
+ const isMainModule =
625
+ import.meta.url === `file://${process.argv[1]}` ||
626
+ process.argv[1]?.endsWith("process_script.ts");
627
+
628
+ if (isMainModule) {
629
+ main();
630
+ }
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
9
+ "jsx": "react-jsx",
10
+ "jsxImportSource": "react",
11
+ "resolveJsonModule": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "esModuleInterop": true,
14
+ "types": ["react", "react-dom"]
15
+ },
16
+ "include": ["./**/*"],
17
+ "exclude": ["node_modules"]
18
+ }
@@ -90,7 +90,7 @@ describe('GraphLegend data logic', () => {
90
90
  const { NODE_COLORS, EDGE_COLORS, TYPE_LABELS } = await import('../packages/frontend/src/constants/graph.js');
91
91
 
92
92
  const expectedNodeTypes = [
93
- 'feature', 'component', 'data_entity', 'decision', 'tech_choice',
93
+ 'feature', 'epic', 'component', 'data_entity', 'decision', 'tech_choice',
94
94
  'non_functional_requirement', 'design_token', 'design_pattern',
95
95
  'user_role', 'flow', 'assumption', 'open_question',
96
96
  ];
@@ -180,6 +180,7 @@ describe('GraphLegend data logic', () => {
180
180
 
181
181
  const expected = {
182
182
  feature: 'Feature',
183
+ epic: 'Epic',
183
184
  component: 'Component',
184
185
  data_entity: 'Data Entity',
185
186
  decision: 'Decision',