@mevdragon/vidfarm-devcli 0.1.0 → 0.2.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 (63) hide show
  1. package/.env.example +11 -4
  2. package/PLATFORM_SPEC.md +142 -2
  3. package/README.md +165 -16
  4. package/SKILL.developer.md +577 -0
  5. package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
  6. package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
  7. package/dist/src/account-pages.js +578 -0
  8. package/dist/src/app.js +887 -66
  9. package/dist/src/cli.js +284 -5
  10. package/dist/src/config.js +24 -4
  11. package/dist/src/db.js +427 -18
  12. package/dist/src/dev-app.js +59 -12
  13. package/dist/src/homepage.js +441 -0
  14. package/dist/src/index.js +12 -7
  15. package/dist/src/lib/crypto.js +14 -0
  16. package/dist/src/lib/template-dna.js +542 -0
  17. package/dist/src/lib/template-style-options.js +49 -0
  18. package/dist/src/registry.js +54 -7
  19. package/dist/src/runtime.js +3 -1
  20. package/dist/src/services/auth.js +69 -5
  21. package/dist/src/services/jobs.js +23 -4
  22. package/dist/src/services/providers.js +74 -12
  23. package/dist/src/services/storage.js +52 -18
  24. package/dist/src/services/template-certification.js +160 -0
  25. package/dist/src/services/template-loader.js +37 -0
  26. package/dist/src/services/template-sources.js +135 -0
  27. package/dist/src/worker.js +19 -7
  28. package/dist/templates/template_0000/src/lib/images.js +242 -0
  29. package/dist/templates/template_0000/src/remotion/Root.js +33 -0
  30. package/dist/templates/template_0000/src/sdk.js +3 -0
  31. package/dist/templates/template_0000/src/style-options.js +51 -0
  32. package/dist/templates/template_0000/src/template-dna.js +9 -0
  33. package/dist/templates/template_0000/src/template.js +1217 -0
  34. package/package.json +9 -1
  35. package/templates/template_0000/README.md +121 -0
  36. package/templates/template_0000/SKILL.md +193 -0
  37. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  38. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  39. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  40. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  41. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  42. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  43. package/templates/template_0000/composition.json +11 -0
  44. package/templates/template_0000/package-lock.json +5137 -0
  45. package/templates/template_0000/package.json +30 -0
  46. package/templates/template_0000/research/preview/.gitkeep +1 -0
  47. package/templates/template_0000/research/source_notes.md +7 -0
  48. package/templates/template_0000/scripts/create-site.mjs +27 -0
  49. package/templates/template_0000/scripts/render-cloud.mjs +72 -0
  50. package/templates/template_0000/src/lib/images.ts +284 -0
  51. package/templates/template_0000/src/remotion/Root.js +33 -0
  52. package/templates/template_0000/src/remotion/Root.tsx +75 -0
  53. package/templates/template_0000/src/remotion/index.tsx +4 -0
  54. package/templates/template_0000/src/sdk.ts +122 -0
  55. package/templates/template_0000/src/style-options.js +51 -0
  56. package/templates/template_0000/src/style-options.ts +60 -0
  57. package/templates/template_0000/src/template-dna.ts +15 -0
  58. package/templates/template_0000/src/template.ts +1747 -0
  59. package/templates/template_0000/template.config.json +26 -0
  60. package/templates/template_0000/tsconfig.json +19 -0
  61. package/dist/templates/template_0000/demo-template.js +0 -196
  62. package/dist/templates/template_0000/remotion/Root.js +0 -66
  63. /package/dist/templates/template_0000/{remotion → src/remotion}/index.js +0 -0
@@ -0,0 +1,542 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const DEFAULT_MODEL = "gemini-3.1-flash-lite-preview";
5
+ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
6
+ const GENERATED_MODULE_RELATIVE_PATH = "src/template-dna.ts";
7
+ const DEFAULT_NOTES_RELATIVE_PATH = "research/source_notes.md";
8
+ const DEFAULT_PREVIEW_RELATIVE_DIR = "research/preview";
9
+ const DEFAULT_VIRAL_OUTPUT_RELATIVE_PATH = "research/viral_dna.extraction.json";
10
+ const DEFAULT_VISUAL_OUTPUT_RELATIVE_PATH = "research/visual_dna.extraction.json";
11
+ export async function analyzeTemplateDna(input) {
12
+ const templateDir = path.resolve(input.templateDir);
13
+ const envFile = path.resolve(input.envFile ?? path.join(process.cwd(), ".env"));
14
+ const notesPath = path.resolve(input.notesPath ?? path.join(templateDir, DEFAULT_NOTES_RELATIVE_PATH));
15
+ const previewDir = path.resolve(input.previewDir ?? path.join(templateDir, DEFAULT_PREVIEW_RELATIVE_DIR));
16
+ const outputPath = path.resolve(input.outputPath ?? path.join(templateDir, input.mode === "viral" ? DEFAULT_VIRAL_OUTPUT_RELATIVE_PATH : DEFAULT_VISUAL_OUTPUT_RELATIVE_PATH));
17
+ const model = input.model || process.env.GEMINI_MODEL || DEFAULT_MODEL;
18
+ const apiKey = resolveGeminiApiKey(envFile);
19
+ const readme = await readOptionalUtf8(notesPath);
20
+ const previewFiles = listPreviewFiles(previewDir);
21
+ if (!previewFiles.length) {
22
+ throw new Error(`No preview media found in ${previewDir}`);
23
+ }
24
+ const uploadedFiles = [];
25
+ try {
26
+ for (const previewFile of previewFiles) {
27
+ const uploaded = await uploadFile(apiKey, previewFile);
28
+ const activeFile = await waitForFileActive(apiKey, uploaded.name);
29
+ uploadedFiles.push({
30
+ name: String(activeFile.name),
31
+ uri: String(activeFile.uri),
32
+ mimeType: typeof activeFile.mimeType === "string" ? activeFile.mimeType : uploaded.mimeType,
33
+ displayName: typeof activeFile.displayName === "string" ? activeFile.displayName : path.basename(previewFile),
34
+ localPath: previewFile
35
+ });
36
+ }
37
+ const prompt = input.mode === "viral"
38
+ ? buildViralPrompt({ templateId: path.basename(templateDir), readme, uploadedFiles })
39
+ : buildVisualPrompt({ templateId: path.basename(templateDir), readme, uploadedFiles });
40
+ const analysis = await generateAnalysis(apiKey, model, prompt, uploadedFiles);
41
+ if (input.mode === "viral") {
42
+ validateViralAnalysis(analysis, path.basename(templateDir));
43
+ }
44
+ else {
45
+ validateVisualAnalysis(analysis, path.basename(templateDir));
46
+ }
47
+ mkdirSync(path.dirname(outputPath), { recursive: true });
48
+ await writeFile(outputPath, JSON.stringify(analysis, null, 2) + "\n", "utf8");
49
+ if (input.syncTemplateModule !== false) {
50
+ syncTemplateDnaModule({
51
+ templateDir,
52
+ linkToOriginal: input.linkToOriginal
53
+ });
54
+ }
55
+ return {
56
+ mode: input.mode,
57
+ model,
58
+ template_dir: templateDir,
59
+ notes_path: notesPath,
60
+ preview_dir: previewDir,
61
+ output_path: outputPath,
62
+ preview_count: uploadedFiles.length,
63
+ title: typeof analysis.title === "string" ? analysis.title : null
64
+ };
65
+ }
66
+ finally {
67
+ for (const uploaded of uploadedFiles) {
68
+ await deleteFileQuietly(apiKey, uploaded.name);
69
+ }
70
+ }
71
+ }
72
+ export function syncTemplateDnaModule(input) {
73
+ const templateDir = path.resolve(input.templateDir);
74
+ const modulePath = path.join(templateDir, GENERATED_MODULE_RELATIVE_PATH);
75
+ const state = loadTemplateDnaModuleState({
76
+ templateDir,
77
+ explicitLinkToOriginal: input.linkToOriginal
78
+ });
79
+ mkdirSync(path.dirname(modulePath), { recursive: true });
80
+ writeFileSync(modulePath, renderTemplateDnaModule(state), "utf8");
81
+ return {
82
+ module_path: modulePath,
83
+ link_to_original: state.linkToOriginal,
84
+ has_viral_analysis: Boolean(state.viralAnalysis),
85
+ has_visual_analysis: Boolean(state.visualAnalysis)
86
+ };
87
+ }
88
+ export function stageTemplateDnaInputs(input) {
89
+ const templateDir = path.resolve(input.templateDir);
90
+ const notesPath = path.join(templateDir, DEFAULT_NOTES_RELATIVE_PATH);
91
+ const previewDir = path.join(templateDir, DEFAULT_PREVIEW_RELATIVE_DIR);
92
+ mkdirSync(path.dirname(notesPath), { recursive: true });
93
+ mkdirSync(previewDir, { recursive: true });
94
+ if (input.sourceNotesPath) {
95
+ cpSync(path.resolve(input.sourceNotesPath), notesPath);
96
+ }
97
+ else if (!existsSync(notesPath)) {
98
+ writeFileSync(notesPath, [
99
+ "# Source Notes",
100
+ "",
101
+ "- original format URL:",
102
+ "- creator/account:",
103
+ "- why it wins:",
104
+ "- what must survive adaptation:",
105
+ "- what can change for new brands:"
106
+ ].join("\n") + "\n", "utf8");
107
+ }
108
+ const keepPath = path.join(previewDir, ".gitkeep");
109
+ if (!existsSync(keepPath)) {
110
+ writeFileSync(keepPath, "", "utf8");
111
+ }
112
+ if (input.sourcePreviewDir) {
113
+ copyDirectoryContents(path.resolve(input.sourcePreviewDir), previewDir);
114
+ }
115
+ return {
116
+ notes_path: notesPath,
117
+ preview_dir: previewDir
118
+ };
119
+ }
120
+ export function hasTemplatePreviewMedia(templateDir) {
121
+ const previewDir = path.join(path.resolve(templateDir), DEFAULT_PREVIEW_RELATIVE_DIR);
122
+ return listPreviewFiles(previewDir).length > 0;
123
+ }
124
+ function loadTemplateDnaModuleState(input) {
125
+ const templateDir = path.resolve(input.templateDir);
126
+ const modulePath = path.join(templateDir, GENERATED_MODULE_RELATIVE_PATH);
127
+ const existingModule = existsSync(modulePath) ? readFileSync(modulePath, "utf8") : "";
128
+ const previewDir = path.join(templateDir, DEFAULT_PREVIEW_RELATIVE_DIR);
129
+ return {
130
+ linkToOriginal: input.explicitLinkToOriginal ?? extractExistingString(existingModule, "templateLinkToOriginal") ?? "",
131
+ viralAnalysis: readAnalysisFile(path.join(templateDir, DEFAULT_VIRAL_OUTPUT_RELATIVE_PATH)),
132
+ visualAnalysis: readAnalysisFile(path.join(templateDir, DEFAULT_VISUAL_OUTPUT_RELATIVE_PATH)),
133
+ notesPath: DEFAULT_NOTES_RELATIVE_PATH,
134
+ previewFiles: listPreviewFiles(previewDir).map((filePath) => path.relative(templateDir, filePath).split(path.sep).join("/"))
135
+ };
136
+ }
137
+ function readAnalysisFile(filePath) {
138
+ if (!existsSync(filePath)) {
139
+ return null;
140
+ }
141
+ return JSON.parse(readFileSync(filePath, "utf8"));
142
+ }
143
+ function extractExistingString(contents, exportName) {
144
+ const match = contents.match(new RegExp(`export const ${exportName} = ([\\s\\S]*?);`));
145
+ if (!match) {
146
+ return null;
147
+ }
148
+ try {
149
+ return JSON.parse(match[1]);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ function renderTemplateDnaModule(state) {
156
+ const viralSummary = summarizeViralDna(state.viralAnalysis);
157
+ const visualSummary = summarizeVisualDna(state.visualAnalysis);
158
+ const viralJson = state.viralAnalysis ? `${JSON.stringify(state.viralAnalysis, null, 2)} as const` : "null";
159
+ const visualJson = state.visualAnalysis ? `${JSON.stringify(state.visualAnalysis, null, 2)} as const` : "null";
160
+ return [
161
+ "// Generated by `vidfarm analyze-viral-dna` and `vidfarm analyze-visual-dna`.",
162
+ "// Keep source notes in `research/source_notes.md` and reference media in `research/preview/`.",
163
+ "",
164
+ `export const templateLinkToOriginal = ${JSON.stringify(state.linkToOriginal)};`,
165
+ `export const templateSourceNotesPath = ${JSON.stringify(state.notesPath)};`,
166
+ `export const templatePreviewMediaRelativePaths = ${JSON.stringify(state.previewFiles, null, 2)} as const;`,
167
+ "",
168
+ `export const templateViralDna = ${JSON.stringify(viralSummary)};`,
169
+ `export const templateVisualDna = ${JSON.stringify(visualSummary)};`,
170
+ "",
171
+ `export const templateViralDnaAnalysis = ${viralJson};`,
172
+ "",
173
+ `export const templateVisualDnaAnalysis = ${visualJson};`,
174
+ ""
175
+ ].join("\n");
176
+ }
177
+ function summarizeViralDna(analysis) {
178
+ if (!analysis) {
179
+ return "Run `vidfarm analyze-viral-dna --template-dir .` after adding source notes and preview media.";
180
+ }
181
+ return [analysis.title.trim(), ...analysis.explanations.map((entry) => entry.trim())]
182
+ .filter(Boolean)
183
+ .join(" ");
184
+ }
185
+ function summarizeVisualDna(analysis) {
186
+ if (!analysis) {
187
+ return "Run `vidfarm analyze-visual-dna --template-dir .` after adding source notes and preview media.";
188
+ }
189
+ const mustPreserve = analysis.must_preserve.slice(0, 3).map((entry) => entry.trim()).filter(Boolean).join("; ");
190
+ const explanation = analysis.explanations[0]?.trim() ?? "";
191
+ return [
192
+ analysis.title.trim(),
193
+ explanation,
194
+ mustPreserve ? `Must preserve: ${mustPreserve}.` : ""
195
+ ].filter(Boolean).join(" ");
196
+ }
197
+ function resolveGeminiApiKey(envFilePath) {
198
+ if (process.env.GEMINI_API_KEY) {
199
+ return process.env.GEMINI_API_KEY;
200
+ }
201
+ if (!existsSync(envFilePath)) {
202
+ throw new Error(`GEMINI_API_KEY is missing and env file was not found: ${envFilePath}`);
203
+ }
204
+ const env = loadEnvFile(envFilePath);
205
+ if (!env.GEMINI_API_KEY) {
206
+ throw new Error(`GEMINI_API_KEY is missing. Checked process env and ${envFilePath}`);
207
+ }
208
+ return env.GEMINI_API_KEY;
209
+ }
210
+ function loadEnvFile(filePath) {
211
+ const text = readFileSync(filePath, "utf8");
212
+ const env = {};
213
+ for (const rawLine of text.split(/\r?\n/)) {
214
+ const line = rawLine.trim();
215
+ if (!line || line.startsWith("#")) {
216
+ continue;
217
+ }
218
+ const separatorIndex = line.indexOf("=");
219
+ if (separatorIndex === -1) {
220
+ continue;
221
+ }
222
+ const key = line.slice(0, separatorIndex).trim();
223
+ let value = line.slice(separatorIndex + 1).trim();
224
+ if ((value.startsWith("\"") && value.endsWith("\"")) ||
225
+ (value.startsWith("'") && value.endsWith("'"))) {
226
+ value = value.slice(1, -1);
227
+ }
228
+ env[key] = value;
229
+ }
230
+ return env;
231
+ }
232
+ function listPreviewFiles(previewDir) {
233
+ if (!existsSync(previewDir)) {
234
+ return [];
235
+ }
236
+ return readdirSync(previewDir, { withFileTypes: true })
237
+ .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
238
+ .map((entry) => path.join(previewDir, entry.name))
239
+ .sort((a, b) => a.localeCompare(b));
240
+ }
241
+ async function readOptionalUtf8(filePath) {
242
+ try {
243
+ return await readFile(filePath, "utf8");
244
+ }
245
+ catch (error) {
246
+ if (error.code === "ENOENT") {
247
+ return "";
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+ function readmeContext(readme, sectionHeading) {
253
+ if (!readme.trim()) {
254
+ return "(missing source notes)";
255
+ }
256
+ const [beforeSection] = readme.split(sectionHeading);
257
+ return beforeSection.trim() || "(source notes file was empty)";
258
+ }
259
+ function buildViralPrompt(input) {
260
+ const mediaSummary = input.uploadedFiles
261
+ .map((file, index) => `${index + 1}. ${path.basename(file.localPath)} (${file.mimeType || "unknown mime"})`)
262
+ .join("\n");
263
+ return [
264
+ "You are a top-tier viral TikTok strategist.",
265
+ "You are analyzing one TikTok winning format.",
266
+ "A template may include multiple preview images or a video. Treat all attached media as ONE format, not separate formats.",
267
+ "If there are multiple slides, infer the repeated pattern that makes the whole format win.",
268
+ "If the preview is a video, analyze the visible hook, framing, pacing implication, emotional trigger, native feel, and why the concept spreads.",
269
+ "Focus on why the creative wins on TikTok: hook, framing, identity signaling, curiosity, emotional tension, status promise, save/share psychology, authority cues, and native execution.",
270
+ "Be specific to the media. Avoid generic advice and avoid academic language.",
271
+ "Return strict JSON with this shape and nothing else:",
272
+ JSON.stringify({
273
+ title: "Short marketer-style label",
274
+ explanations: ["", "", "", "", ""]
275
+ }),
276
+ "Each explanation must be 2-4 sentences.",
277
+ "Write in the voice of a sharp viral TikTok marketer.",
278
+ "",
279
+ "TEMPLATE",
280
+ `template_id: ${input.templateId}`,
281
+ "",
282
+ "README CONTEXT",
283
+ readmeContext(input.readme, /^## Template Viral DNA\b/m),
284
+ "",
285
+ "ATTACHED MEDIA",
286
+ mediaSummary
287
+ ].join("\n");
288
+ }
289
+ function buildVisualPrompt(input) {
290
+ const mediaSummary = input.uploadedFiles
291
+ .map((file, index) => `${index + 1}. ${path.basename(file.localPath)} (${file.mimeType || "unknown mime"})`)
292
+ .join("\n");
293
+ return [
294
+ "You are extracting the transferable visual DNA of one TikTok winning format.",
295
+ "Treat all attached media as ONE format, even if there are multiple slides, frames, or a video.",
296
+ "Your job is to capture the visual system that should survive adaptation into new proposals.",
297
+ "Focus on composition, framing, camera feel, text treatment, caption behavior, pacing cues visible on screen, lighting, color logic, safe-zone usage, subject staging, and what makes the asset feel natively TikTok.",
298
+ "First classify what actually makes the template recognizable at a glance.",
299
+ "- `character-centric`: recognizability comes mainly from a recurring character, person, mascot, illustrated archetype, or character-design language.",
300
+ "- `vibe-centric`: recognizability comes mainly from mood, awkwardness, chaos, polish level, emotional tone, texture, lighting, or scene energy.",
301
+ "- `presentation-centric`: recognizability comes mainly from the presentation mechanic or container, such as a tier list, chat interface, phone screenshot logic, notifications, notes app, mind map, calendar, whiteboard, search bar, or diagram format.",
302
+ "Choose one dominant `recognition_mode` even for mixed templates, then list any supporting modes in `secondary_recognition_modes`.",
303
+ "Ignore TikTok app UI and platform chrome completely: no tabs, username rows, caption bar UI, action rail, comments UI, progress bars, watermarks, search bars, or app frames.",
304
+ "However, keep creator-authored caption behavior in the analysis when it is part of the creative pattern.",
305
+ "Extract the reusable visual recipe, not the specific identity of one actor, creator, product, or character.",
306
+ "Be concrete about what should stay visually consistent and what can change across new proposals.",
307
+ "Return strict JSON with this shape and nothing else:",
308
+ JSON.stringify({
309
+ title: "Short visual-system label",
310
+ recognition_mode: "presentation-centric",
311
+ secondary_recognition_modes: ["vibe-centric"],
312
+ presentation_mechanic: "Short label for the dominant visual container or mechanic",
313
+ character_identity_strength: "low",
314
+ vibe_lock_strength: "medium",
315
+ layout_lock_strength: "high",
316
+ must_preserve: ["", "", "", ""],
317
+ can_adapt: ["", "", ""],
318
+ explanations: ["", "", "", "", ""]
319
+ }),
320
+ "`recognition_mode` and `secondary_recognition_modes` must only use: `character-centric`, `vibe-centric`, `presentation-centric`.",
321
+ "`character_identity_strength`, `vibe_lock_strength`, and `layout_lock_strength` must only use: `none`, `low`, `medium`, `high`.",
322
+ "`must_preserve` should list the most binding visual rules that make the template recognizable.",
323
+ "`can_adapt` should list what can safely change for new brands or concepts without breaking the format.",
324
+ "Each explanation must be 2-4 sentences.",
325
+ "Write in the voice of a sharp creative director for TikTok-native performance media.",
326
+ "",
327
+ "TEMPLATE",
328
+ `template_id: ${input.templateId}`,
329
+ "",
330
+ "README CONTEXT",
331
+ readmeContext(input.readme, /^## Template Visual DNA\b/m),
332
+ "",
333
+ "ATTACHED MEDIA",
334
+ mediaSummary
335
+ ].join("\n");
336
+ }
337
+ function inferMimeType(filePath) {
338
+ const ext = path.extname(filePath).toLowerCase();
339
+ if (ext === ".png")
340
+ return "image/png";
341
+ if (ext === ".jpg" || ext === ".jpeg")
342
+ return "image/jpeg";
343
+ if (ext === ".webp")
344
+ return "image/webp";
345
+ if (ext === ".mp4")
346
+ return "video/mp4";
347
+ if (ext === ".mov")
348
+ return "video/quicktime";
349
+ if (ext === ".webm")
350
+ return "video/webm";
351
+ return "application/octet-stream";
352
+ }
353
+ function unwrapFilePayload(payload) {
354
+ return (payload.file ?? payload);
355
+ }
356
+ async function uploadFile(apiKey, filePath) {
357
+ const fileBuffer = await readFile(filePath);
358
+ const mimeType = inferMimeType(filePath);
359
+ const displayName = path.basename(filePath);
360
+ const startResponse = await fetch(`${GEMINI_BASE_URL}/upload/v1beta/files?key=${apiKey}`, {
361
+ method: "POST",
362
+ headers: {
363
+ "X-Goog-Upload-Protocol": "resumable",
364
+ "X-Goog-Upload-Command": "start",
365
+ "X-Goog-Upload-Header-Content-Length": String(fileBuffer.length),
366
+ "X-Goog-Upload-Header-Content-Type": mimeType,
367
+ "Content-Type": "application/json"
368
+ },
369
+ body: JSON.stringify({
370
+ file: {
371
+ display_name: displayName
372
+ }
373
+ })
374
+ });
375
+ if (!startResponse.ok) {
376
+ throw new Error(`Failed to start upload for ${filePath}: ${startResponse.status} ${await startResponse.text()}`);
377
+ }
378
+ const uploadUrl = startResponse.headers.get("x-goog-upload-url");
379
+ if (!uploadUrl) {
380
+ throw new Error(`Upload URL missing for ${filePath}`);
381
+ }
382
+ const finalizeResponse = await fetch(uploadUrl, {
383
+ method: "POST",
384
+ headers: {
385
+ "Content-Length": String(fileBuffer.length),
386
+ "X-Goog-Upload-Offset": "0",
387
+ "X-Goog-Upload-Command": "upload, finalize"
388
+ },
389
+ body: fileBuffer
390
+ });
391
+ if (!finalizeResponse.ok) {
392
+ throw new Error(`Failed to upload ${filePath}: ${finalizeResponse.status} ${await finalizeResponse.text()}`);
393
+ }
394
+ const payload = await finalizeResponse.json();
395
+ const file = unwrapFilePayload(payload);
396
+ if (typeof file.name !== "string") {
397
+ throw new Error(`Upload response missing file name for ${filePath}`);
398
+ }
399
+ return {
400
+ name: file.name,
401
+ uri: typeof file.uri === "string" ? file.uri : "",
402
+ mimeType: typeof file.mimeType === "string" ? file.mimeType : mimeType,
403
+ displayName
404
+ };
405
+ }
406
+ async function getFile(apiKey, fileName) {
407
+ const response = await fetch(`${GEMINI_BASE_URL}/v1beta/${fileName}?key=${apiKey}`);
408
+ if (!response.ok) {
409
+ throw new Error(`Failed to fetch ${fileName}: ${response.status} ${await response.text()}`);
410
+ }
411
+ return unwrapFilePayload(await response.json());
412
+ }
413
+ async function waitForFileActive(apiKey, fileName) {
414
+ for (let attempt = 0; attempt < 60; attempt += 1) {
415
+ const file = await getFile(apiKey, fileName);
416
+ if (file.state === "ACTIVE") {
417
+ return file;
418
+ }
419
+ if (typeof file.state === "string" && file.state !== "PROCESSING") {
420
+ throw new Error(`File ${fileName} entered unexpected state ${file.state}`);
421
+ }
422
+ await delay(1000);
423
+ }
424
+ throw new Error(`Timed out waiting for ${fileName} to become ACTIVE`);
425
+ }
426
+ async function generateAnalysis(apiKey, model, prompt, uploadedFiles) {
427
+ const response = await fetch(`${GEMINI_BASE_URL}/v1beta/models/${model}:generateContent?key=${apiKey}`, {
428
+ method: "POST",
429
+ headers: {
430
+ "Content-Type": "application/json"
431
+ },
432
+ body: JSON.stringify({
433
+ contents: [
434
+ {
435
+ role: "user",
436
+ parts: [
437
+ { text: prompt },
438
+ ...uploadedFiles.map((file) => ({
439
+ fileData: {
440
+ mimeType: file.mimeType,
441
+ fileUri: file.uri
442
+ }
443
+ }))
444
+ ]
445
+ }
446
+ ],
447
+ generationConfig: {
448
+ temperature: 0.7,
449
+ responseMimeType: "application/json"
450
+ }
451
+ })
452
+ });
453
+ if (!response.ok) {
454
+ throw new Error(`Gemini request failed (${response.status}): ${await response.text()}`);
455
+ }
456
+ const payload = await response.json();
457
+ const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
458
+ const first = candidates[0];
459
+ const content = first?.content;
460
+ const parts = Array.isArray(content?.parts) ? content.parts : [];
461
+ const text = parts
462
+ .map((part) => {
463
+ const record = part;
464
+ return typeof record.text === "string" ? record.text : "";
465
+ })
466
+ .join("\n")
467
+ .trim();
468
+ if (!text) {
469
+ throw new Error("Gemini returned no text content");
470
+ }
471
+ const normalized = text
472
+ .replace(/^```json\s*/i, "")
473
+ .replace(/^```\s*/i, "")
474
+ .replace(/\s*```$/, "")
475
+ .trim();
476
+ return JSON.parse(normalized);
477
+ }
478
+ function validateViralAnalysis(analysis, templateId) {
479
+ if (typeof analysis.title !== "string" || !analysis.title.trim()) {
480
+ throw new Error(`${templateId} analysis is missing title`);
481
+ }
482
+ if (!Array.isArray(analysis.explanations) || analysis.explanations.length !== 5) {
483
+ throw new Error(`${templateId} analysis must include exactly 5 explanations`);
484
+ }
485
+ for (const explanation of analysis.explanations) {
486
+ if (typeof explanation !== "string" || !explanation.trim()) {
487
+ throw new Error(`${templateId} analysis contains an empty explanation`);
488
+ }
489
+ }
490
+ }
491
+ function validateVisualAnalysis(analysis, templateId) {
492
+ if (typeof analysis.title !== "string" || !analysis.title.trim()) {
493
+ throw new Error(`${templateId} analysis is missing title`);
494
+ }
495
+ const recognitionModes = new Set(["character-centric", "vibe-centric", "presentation-centric"]);
496
+ if (!recognitionModes.has(String(analysis.recognition_mode || "").trim())) {
497
+ throw new Error(`${templateId} analysis has invalid recognition_mode`);
498
+ }
499
+ if (!Array.isArray(analysis.secondary_recognition_modes) ||
500
+ analysis.secondary_recognition_modes.some((mode) => !recognitionModes.has(String(mode || "").trim()))) {
501
+ throw new Error(`${templateId} analysis has invalid secondary_recognition_modes`);
502
+ }
503
+ const strengths = new Set(["none", "low", "medium", "high"]);
504
+ if (!strengths.has(String(analysis.character_identity_strength || "").trim())) {
505
+ throw new Error(`${templateId} analysis has invalid character_identity_strength`);
506
+ }
507
+ if (!strengths.has(String(analysis.vibe_lock_strength || "").trim())) {
508
+ throw new Error(`${templateId} analysis has invalid vibe_lock_strength`);
509
+ }
510
+ if (!strengths.has(String(analysis.layout_lock_strength || "").trim())) {
511
+ throw new Error(`${templateId} analysis has invalid layout_lock_strength`);
512
+ }
513
+ if (typeof analysis.presentation_mechanic !== "string" || !analysis.presentation_mechanic.trim()) {
514
+ throw new Error(`${templateId} analysis is missing presentation_mechanic`);
515
+ }
516
+ if (!Array.isArray(analysis.must_preserve) || analysis.must_preserve.length < 3) {
517
+ throw new Error(`${templateId} analysis must include at least 3 must_preserve rules`);
518
+ }
519
+ if (!Array.isArray(analysis.can_adapt) || analysis.can_adapt.length < 2) {
520
+ throw new Error(`${templateId} analysis must include at least 2 can_adapt rules`);
521
+ }
522
+ if (!Array.isArray(analysis.explanations) || !analysis.explanations.length) {
523
+ throw new Error(`${templateId} analysis must include at least 1 explanation`);
524
+ }
525
+ }
526
+ async function deleteFileQuietly(apiKey, fileName) {
527
+ try {
528
+ await fetch(`${GEMINI_BASE_URL}/v1beta/${fileName}?key=${apiKey}`, { method: "DELETE" });
529
+ }
530
+ catch { }
531
+ }
532
+ function copyDirectoryContents(sourceDir, destinationDir) {
533
+ mkdirSync(destinationDir, { recursive: true });
534
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
535
+ if (!entry.name.startsWith(".")) {
536
+ cpSync(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name), { recursive: true });
537
+ }
538
+ }
539
+ }
540
+ function delay(ms) {
541
+ return new Promise((resolve) => setTimeout(resolve, ms));
542
+ }
@@ -0,0 +1,49 @@
1
+ export const STARTER_TEMPLATE_FONT_OPTIONS = [
2
+ {
3
+ id: "source_code_pro",
4
+ label: "Source Code Pro",
5
+ family: "Source Code Pro",
6
+ assetFile: "SourceCodePro[wght].ttf"
7
+ },
8
+ {
9
+ id: "montserrat",
10
+ label: "Montserrat",
11
+ family: "Montserrat",
12
+ assetFile: "Montserrat[wght].ttf"
13
+ },
14
+ {
15
+ id: "yesteryear",
16
+ label: "Yesteryear",
17
+ family: "Yesteryear",
18
+ assetFile: "Yesteryear-Regular.ttf"
19
+ },
20
+ {
21
+ id: "dm_serif_display",
22
+ label: "DM Serif Display",
23
+ family: "DM Serif Display",
24
+ assetFile: "DMSerifDisplay-Regular.ttf"
25
+ },
26
+ {
27
+ id: "abel",
28
+ label: "Abel",
29
+ family: "Abel",
30
+ assetFile: "Abel-Regular.ttf"
31
+ }
32
+ ];
33
+ export const STARTER_TEMPLATE_TEXT_BACKGROUND_COLOR_OPTIONS = [
34
+ { id: "black", label: "Black", hex: "#000000" },
35
+ { id: "red", label: "Red", hex: "#EA403F" },
36
+ { id: "orange", label: "Orange", hex: "#FF933D" },
37
+ { id: "yellow", label: "Yellow", hex: "#F2CD46" },
38
+ { id: "lime_green", label: "Lime Green", hex: "#78C25E" },
39
+ { id: "teal", label: "Teal", hex: "#77C8A6" },
40
+ { id: "light_blue", label: "Light Blue", hex: "#3496F0" },
41
+ { id: "dark_blue", label: "Dark Blue", hex: "#3496F0" },
42
+ { id: "violet", label: "Violet", hex: "#5756D4" },
43
+ { id: "pink", label: "Pink", hex: "#F7D7E9" },
44
+ { id: "brown", label: "Brown", hex: "#A3895B" },
45
+ { id: "dark_green", label: "Dark Green", hex: "#32523B" },
46
+ { id: "blue_gray", label: "Blue Gray", hex: "#2F688C" },
47
+ { id: "light_gray", label: "Light Gray", hex: "#92979E" },
48
+ { id: "dark_gray", label: "Dark Gray", hex: "#333333" }
49
+ ];
@@ -1,10 +1,57 @@
1
- import { demoTemplate } from "../templates/template_0000/demo-template.js";
2
- const templates = [demoTemplate];
3
- export const templateRegistry = {
1
+ import { database } from "./db.js";
2
+ import { template0000Definition } from "../templates/template_0000/src/template.js";
3
+ import { loadTemplateFromModule } from "./services/template-loader.js";
4
+ import { TemplateCertificationService } from "./services/template-certification.js";
5
+ class TemplateRegistry {
6
+ localTemplates = new Map([[
7
+ template0000Definition.id,
8
+ template0000Definition
9
+ ]]);
10
+ runtimeTemplates = new Map();
11
+ certification = new TemplateCertificationService();
12
+ initialized = false;
13
+ initPromise = null;
14
+ async ensureInitialized() {
15
+ if (this.initialized) {
16
+ return;
17
+ }
18
+ if (this.initPromise) {
19
+ return this.initPromise;
20
+ }
21
+ this.initPromise = (async () => {
22
+ for (const template of this.localTemplates.values()) {
23
+ const skillPath = template.skillPath;
24
+ if (!skillPath) {
25
+ throw new Error(`Local template ${template.id} is missing skillPath.`);
26
+ }
27
+ const report = await this.certification.certify({ template, skillPath });
28
+ if (!report.passed) {
29
+ throw new Error(`Local template ${template.id} failed certification.`);
30
+ }
31
+ }
32
+ for (const release of database.getActiveTemplateReleases()) {
33
+ const template = await loadTemplateFromModule(release.modulePath);
34
+ this.runtimeTemplates.set(release.templateId, {
35
+ ...template,
36
+ skillPath: release.skillPath
37
+ });
38
+ }
39
+ this.initialized = true;
40
+ this.initPromise = null;
41
+ })();
42
+ return this.initPromise;
43
+ }
4
44
  list() {
5
- return templates;
6
- },
45
+ return [...this.localTemplates.values(), ...this.runtimeTemplates.values()].sort((a, b) => a.id.localeCompare(b.id));
46
+ }
7
47
  get(templateId) {
8
- return templates.find((template) => template.id === templateId) ?? null;
48
+ return this.findTemplate(templateId, this.runtimeTemplates) ?? this.findTemplate(templateId, this.localTemplates);
49
+ }
50
+ registerRuntimeTemplate(template) {
51
+ this.runtimeTemplates.set(template.id, template);
52
+ }
53
+ findTemplate(templateKey, source) {
54
+ return source.get(templateKey) ?? [...source.values()].find((template) => template.slugId === templateKey) ?? null;
9
55
  }
10
- };
56
+ }
57
+ export const templateRegistry = new TemplateRegistry();
@@ -1,8 +1,10 @@
1
1
  import { serve } from "@hono/node-server";
2
2
  import app from "./app.js";
3
3
  import { config } from "./config.js";
4
+ import { templateRegistry } from "./registry.js";
4
5
  import { Worker } from "./worker.js";
5
- export function startRuntime() {
6
+ export async function startRuntime() {
7
+ await templateRegistry.ensureInitialized();
6
8
  const worker = new Worker();
7
9
  worker.start();
8
10
  const server = serve({