@mevdragon/vidfarm-devcli 0.2.8 → 0.2.10
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/.env.example +9 -0
- package/GETTING_STARTED.developers.md +1 -26
- package/README.md +3 -0
- package/SKILL.developer.md +419 -8
- package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +475 -0
- package/auto-create-templates/extractor-system-prompt.md +113 -0
- package/auto-create-templates/production-graph.schema.json +276 -0
- package/auto-create-templates/runbook.md +140 -0
- package/auto-create-templates/template-plan.schema.json +230 -0
- package/auto-create-templates/template-planner-prompt.md +75 -0
- package/dist/src/account-pages-legacy.js +9064 -0
- package/dist/src/account-pages.js +41 -620
- package/dist/src/app.js +6627 -244
- package/dist/src/cli.js +386 -26
- package/dist/src/config.js +12 -2
- package/dist/src/context.js +35 -35
- package/dist/src/db.js +1016 -53
- package/dist/src/dev-app-legacy.js +693 -0
- package/dist/src/dev-app.js +4 -904
- package/dist/src/domain.js +1 -1
- package/dist/src/editor-chat-history.js +72 -0
- package/dist/src/editor-chat.js +202 -0
- package/dist/src/frontend/flockposter-cache-store.js +116 -0
- package/dist/src/frontend/homepage-client.js +114 -0
- package/dist/src/frontend/homepage-shared.js +3 -0
- package/dist/src/frontend/homepage-store.js +27 -0
- package/dist/src/frontend/homepage-view.js +147 -0
- package/dist/src/frontend/page-runtime-client.js +100 -0
- package/dist/src/frontend/page-runtime-store.js +8 -0
- package/dist/src/frontend/template-editor-chat.js +2261 -0
- package/dist/src/homepage.js +695 -371
- package/dist/src/lib/template-style-options.js +95 -15
- package/dist/src/page-runtime.js +1 -0
- package/dist/src/page-shell.js +941 -0
- package/dist/src/primitive-context.js +163 -0
- package/dist/src/primitive-registry.js +340 -0
- package/dist/src/primitive-sdk.js +3 -0
- package/dist/src/primitives/remotion/html-image.js +28 -0
- package/dist/src/react-page-shell.js +34 -0
- package/dist/src/ready-post-schedule-component.js +1514 -0
- package/dist/src/registry.js +36 -17
- package/dist/src/runtime.js +6 -1
- package/dist/src/services/api-call-history.js +245 -0
- package/dist/src/services/auth.js +25 -3
- package/dist/src/services/billing.js +248 -9
- package/dist/src/services/chat-threads.js +88 -0
- package/dist/src/services/job-logs.js +14 -7
- package/dist/src/services/jobs.js +13 -2
- package/dist/src/services/providers.js +552 -133
- package/dist/src/services/rate-limits.js +236 -0
- package/dist/src/services/remotion.js +143 -16
- package/dist/src/services/storage.js +10 -8
- package/dist/src/services/template-certification.js +11 -2
- package/dist/src/services/template-loader.js +3 -1
- package/dist/src/services/template-sources.js +68 -3
- package/dist/src/template-editor-pages.js +2309 -0
- package/dist/src/template-editor-shell.js +1507 -0
- package/dist/src/worker.js +120 -22
- package/package.json +27 -4
- package/public/assets/homepage-app.js +54 -0
- package/public/assets/homepage-client-app.js +54 -0
- package/public/assets/page-runtime-client-app.js +68 -0
- package/dist/templates/vidfarm_template_0000/src/lib/images.js +0 -202
- package/dist/templates/vidfarm_template_0000/src/remotion/Root.js +0 -34
- package/dist/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
- package/dist/templates/vidfarm_template_0000/src/sdk.js +0 -3
- package/dist/templates/vidfarm_template_0000/src/style-options.js +0 -51
- package/dist/templates/vidfarm_template_0000/src/template-dna.js +0 -9
- package/dist/templates/vidfarm_template_0000/src/template.js +0 -1447
- package/templates/vidfarm_template_0000/README.md +0 -100
- package/templates/vidfarm_template_0000/SKILL.md +0 -225
- package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/composition.json +0 -11
- package/templates/vidfarm_template_0000/package.json +0 -27
- package/templates/vidfarm_template_0000/research/preview/.gitkeep +0 -1
- package/templates/vidfarm_template_0000/research/source_notes.md +0 -7
- package/templates/vidfarm_template_0000/src/lib/images.js +0 -202
- package/templates/vidfarm_template_0000/src/lib/images.ts +0 -241
- package/templates/vidfarm_template_0000/src/remotion/Root.js +0 -33
- package/templates/vidfarm_template_0000/src/remotion/Root.tsx +0 -75
- package/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
- package/templates/vidfarm_template_0000/src/remotion/index.tsx +0 -4
- package/templates/vidfarm_template_0000/src/sdk.js +0 -3
- package/templates/vidfarm_template_0000/src/sdk.ts +0 -140
- package/templates/vidfarm_template_0000/src/style-options.js +0 -51
- package/templates/vidfarm_template_0000/src/style-options.ts +0 -60
- package/templates/vidfarm_template_0000/src/template-dna.js +0 -9
- package/templates/vidfarm_template_0000/src/template-dna.ts +0 -15
- package/templates/vidfarm_template_0000/src/template.js +0 -1447
- package/templates/vidfarm_template_0000/src/template.ts +0 -2049
- package/templates/vidfarm_template_0000/template.config.json +0 -21
- package/templates/vidfarm_template_0000/tmp/solobacterium-moorei-slideshow.request.json +0 -31
- package/templates/vidfarm_template_0000/tsconfig.json +0 -19
package/dist/src/registry.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { database } from "./db.js";
|
|
2
|
-
import { template0000Definition } from "../templates/vidfarm_template_0000/src/template.js";
|
|
3
4
|
import { loadTemplateFromModule } from "./services/template-loader.js";
|
|
4
|
-
import { TemplateCertificationService } from "./services/template-certification.js";
|
|
5
5
|
class TemplateRegistry {
|
|
6
|
-
localTemplates = new Map(
|
|
7
|
-
template0000Definition.id,
|
|
8
|
-
template0000Definition
|
|
9
|
-
]]);
|
|
6
|
+
localTemplates = new Map();
|
|
10
7
|
runtimeTemplates = new Map();
|
|
11
|
-
certification = new TemplateCertificationService();
|
|
12
8
|
initialized = false;
|
|
13
9
|
initPromise = null;
|
|
14
10
|
async ensureInitialized() {
|
|
@@ -19,22 +15,24 @@ class TemplateRegistry {
|
|
|
19
15
|
return this.initPromise;
|
|
20
16
|
}
|
|
21
17
|
this.initPromise = (async () => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
await this.loadLocalTemplates();
|
|
19
|
+
const visibleReleaseByTemplateId = new Map();
|
|
20
|
+
for (const release of database.listTemplateReleases()) {
|
|
21
|
+
if (release.status !== "approved" && release.status !== "active") {
|
|
22
|
+
continue;
|
|
26
23
|
}
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
29
|
-
|
|
24
|
+
const current = visibleReleaseByTemplateId.get(release.templateId);
|
|
25
|
+
if (!current || getReleaseTimestamp(release) > getReleaseTimestamp(current)) {
|
|
26
|
+
visibleReleaseByTemplateId.set(release.templateId, release);
|
|
30
27
|
}
|
|
31
28
|
}
|
|
32
|
-
for (const release of
|
|
29
|
+
for (const release of visibleReleaseByTemplateId.values()) {
|
|
33
30
|
const template = await loadTemplateFromModule(release.modulePath);
|
|
34
|
-
|
|
31
|
+
const runtimeTemplate = {
|
|
35
32
|
...template,
|
|
36
33
|
skillPath: release.skillPath
|
|
37
|
-
}
|
|
34
|
+
};
|
|
35
|
+
this.runtimeTemplates.set(release.templateId, runtimeTemplate);
|
|
38
36
|
}
|
|
39
37
|
this.initialized = true;
|
|
40
38
|
this.initPromise = null;
|
|
@@ -57,8 +55,29 @@ class TemplateRegistry {
|
|
|
57
55
|
registerRuntimeTemplate(template) {
|
|
58
56
|
this.runtimeTemplates.set(template.id, template);
|
|
59
57
|
}
|
|
58
|
+
async loadLocalTemplates() {
|
|
59
|
+
this.localTemplates.clear();
|
|
60
|
+
for (const modulePath of discoverLocalTemplateModulePaths(process.cwd())) {
|
|
61
|
+
const template = await loadTemplateFromModule(modulePath);
|
|
62
|
+
this.localTemplates.set(template.id, template);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
60
65
|
findTemplate(templateKey, source) {
|
|
61
66
|
return source.get(templateKey) ?? [...source.values()].find((template) => template.slugId === templateKey) ?? null;
|
|
62
67
|
}
|
|
63
68
|
}
|
|
69
|
+
function getReleaseTimestamp(release) {
|
|
70
|
+
return Date.parse(release.activatedAt ?? release.updatedAt ?? release.createdAt);
|
|
71
|
+
}
|
|
72
|
+
function discoverLocalTemplateModulePaths(rootDir) {
|
|
73
|
+
const templatesDir = path.resolve(rootDir, "templates");
|
|
74
|
+
if (!existsSync(templatesDir)) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return readdirSync(templatesDir, { withFileTypes: true })
|
|
78
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("vidfarm_template_"))
|
|
79
|
+
.map((entry) => path.join(templatesDir, entry.name, "src", "template.ts"))
|
|
80
|
+
.filter((modulePath) => existsSync(modulePath))
|
|
81
|
+
.sort((a, b) => a.localeCompare(b));
|
|
82
|
+
}
|
|
64
83
|
export const templateRegistry = new TemplateRegistry();
|
package/dist/src/runtime.js
CHANGED
|
@@ -2,6 +2,7 @@ import { serve } from "@hono/node-server";
|
|
|
2
2
|
import app from "./app.js";
|
|
3
3
|
import { config } from "./config.js";
|
|
4
4
|
import { templateRegistry } from "./registry.js";
|
|
5
|
+
import { apiCallHistoryStore } from "./services/api-call-history.js";
|
|
5
6
|
import { jobLogStore } from "./services/job-logs.js";
|
|
6
7
|
import { Worker } from "./worker.js";
|
|
7
8
|
export async function startRuntime() {
|
|
@@ -10,12 +11,16 @@ export async function startRuntime() {
|
|
|
10
11
|
worker.start();
|
|
11
12
|
const server = serve({
|
|
12
13
|
fetch: app.fetch,
|
|
13
|
-
port: config.PORT
|
|
14
|
+
port: config.PORT,
|
|
15
|
+
serverOptions: {
|
|
16
|
+
maxHeaderSize: 64 * 1024
|
|
17
|
+
}
|
|
14
18
|
}, (info) => {
|
|
15
19
|
console.log(`[vidfarm] listening on http://localhost:${info.port}`);
|
|
16
20
|
});
|
|
17
21
|
const shutdown = async () => {
|
|
18
22
|
worker.stop();
|
|
23
|
+
await apiCallHistoryStore.shutdown();
|
|
19
24
|
await jobLogStore.shutdown();
|
|
20
25
|
server.close();
|
|
21
26
|
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { StorageService } from "./storage.js";
|
|
2
|
+
const API_CALL_HISTORY_PREFIX = "api-call-history";
|
|
3
|
+
const CHUNK_INTERVAL_MS = 60_000;
|
|
4
|
+
const CHUNK_MAX_BYTES = 1024 * 1024;
|
|
5
|
+
const FLUSH_INTERVAL_MS = 5_000;
|
|
6
|
+
export class ApiCallHistoryStore {
|
|
7
|
+
storage = new StorageService();
|
|
8
|
+
activeChunks = new Map();
|
|
9
|
+
flushTimer = setInterval(() => {
|
|
10
|
+
void this.flushExpiredChunks();
|
|
11
|
+
}, FLUSH_INTERVAL_MS);
|
|
12
|
+
constructor() {
|
|
13
|
+
this.flushTimer.unref();
|
|
14
|
+
}
|
|
15
|
+
addEntry(entry) {
|
|
16
|
+
const normalized = {
|
|
17
|
+
...entry,
|
|
18
|
+
createdAt: entry.createdAt ?? new Date().toISOString()
|
|
19
|
+
};
|
|
20
|
+
const minuteStartMs = floorToMinute(Date.parse(normalized.createdAt));
|
|
21
|
+
const activePrefix = this.activeChunkPrefix(normalized.customerId, minuteStartMs);
|
|
22
|
+
const current = this.findLatestChunk(activePrefix);
|
|
23
|
+
const entryBytes = serializedEntrySize(normalized);
|
|
24
|
+
const chunk = current && current.sizeBytes + entryBytes <= CHUNK_MAX_BYTES
|
|
25
|
+
? current
|
|
26
|
+
: this.createChunk(normalized.customerId, minuteStartMs, current ? current.sequence + 1 : 0);
|
|
27
|
+
chunk.entries.push(normalized);
|
|
28
|
+
chunk.sizeBytes += entryBytes;
|
|
29
|
+
chunk.dirty = true;
|
|
30
|
+
if (chunk.sizeBytes >= CHUNK_MAX_BYTES) {
|
|
31
|
+
void this.persistChunk(chunk, true);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async listEntries(input) {
|
|
35
|
+
const persisted = await this.readPersistedEntries(input);
|
|
36
|
+
const buffered = this.readBufferedEntries(input);
|
|
37
|
+
const merged = dedupeAndSort([...persisted, ...buffered]).reverse();
|
|
38
|
+
const filtered = input.cursor
|
|
39
|
+
? merged.filter((entry) => (Date.parse(entry.createdAt) < Date.parse(input.cursor.createdAt)
|
|
40
|
+
|| (entry.createdAt === input.cursor.createdAt && entry.id < input.cursor.id)))
|
|
41
|
+
: merged;
|
|
42
|
+
if (input.limit === undefined) {
|
|
43
|
+
return filtered;
|
|
44
|
+
}
|
|
45
|
+
const limit = Math.max(1, Math.min(input.limit, 501));
|
|
46
|
+
return filtered.slice(0, limit);
|
|
47
|
+
}
|
|
48
|
+
async renameTracer(input) {
|
|
49
|
+
const fromTracer = input.fromTracer.trim();
|
|
50
|
+
const toTracer = input.toTracer.trim();
|
|
51
|
+
if (!fromTracer || !toTracer || fromTracer === toTracer) {
|
|
52
|
+
return { updated: 0 };
|
|
53
|
+
}
|
|
54
|
+
let updated = 0;
|
|
55
|
+
for (const chunk of this.activeChunks.values()) {
|
|
56
|
+
if (chunk.customerId !== input.customerId) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let chunkChanged = false;
|
|
60
|
+
for (const entry of chunk.entries) {
|
|
61
|
+
if (entry.tracer === fromTracer && withinRange(entry.createdAt, input.startTime, input.endTime)) {
|
|
62
|
+
entry.tracer = toTracer;
|
|
63
|
+
updated += 1;
|
|
64
|
+
chunkChanged = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (chunkChanged) {
|
|
68
|
+
chunk.dirty = true;
|
|
69
|
+
chunk.sizeBytes = chunk.entries.reduce((sum, entry) => sum + serializedEntrySize(entry), 0);
|
|
70
|
+
await this.persistChunk(chunk, false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const keys = await this.listRelevantKeys(input);
|
|
74
|
+
await Promise.all(keys.map(async (key) => {
|
|
75
|
+
const content = await this.storage.readText(key);
|
|
76
|
+
const entries = parseChunk(content);
|
|
77
|
+
let changed = false;
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.customerId === input.customerId
|
|
80
|
+
&& entry.tracer === fromTracer
|
|
81
|
+
&& withinRange(entry.createdAt, input.startTime, input.endTime)) {
|
|
82
|
+
entry.tracer = toTracer;
|
|
83
|
+
updated += 1;
|
|
84
|
+
changed = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (changed) {
|
|
88
|
+
const body = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
89
|
+
await this.storage.putText(key, body, "application/x-ndjson; charset=utf-8");
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
return { updated };
|
|
93
|
+
}
|
|
94
|
+
async shutdown() {
|
|
95
|
+
clearInterval(this.flushTimer);
|
|
96
|
+
await Promise.all([...this.activeChunks.values()].map((chunk) => this.persistChunk(chunk, true)));
|
|
97
|
+
}
|
|
98
|
+
async readPersistedEntries(input) {
|
|
99
|
+
const keys = await this.listRelevantKeys(input);
|
|
100
|
+
const entries = await Promise.all(keys.map(async (key) => parseChunk(await this.storage.readText(key))));
|
|
101
|
+
return entries
|
|
102
|
+
.flat()
|
|
103
|
+
.filter((entry) => entry.customerId === input.customerId)
|
|
104
|
+
.filter((entry) => withinRange(entry.createdAt, input.startTime, input.endTime));
|
|
105
|
+
}
|
|
106
|
+
readBufferedEntries(input) {
|
|
107
|
+
const buffered = [];
|
|
108
|
+
for (const chunk of this.activeChunks.values()) {
|
|
109
|
+
if (chunk.customerId !== input.customerId) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
for (const entry of chunk.entries.slice(chunk.lastPersistedEntryCount)) {
|
|
113
|
+
if (withinRange(entry.createdAt, input.startTime, input.endTime)) {
|
|
114
|
+
buffered.push(entry);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return buffered;
|
|
119
|
+
}
|
|
120
|
+
async listRelevantKeys(input) {
|
|
121
|
+
const minuteStarts = this.minuteStartsForQuery(input.startTime, input.endTime);
|
|
122
|
+
if (minuteStarts === null) {
|
|
123
|
+
return this.storage.listKeys(`${API_CALL_HISTORY_PREFIX}/customer_id=${input.customerId}/`);
|
|
124
|
+
}
|
|
125
|
+
const nested = await Promise.all(minuteStarts.map((minuteStartMs) => (this.storage.listKeys(this.minutePrefix(input.customerId, minuteStartMs)))));
|
|
126
|
+
return nested.flat();
|
|
127
|
+
}
|
|
128
|
+
minuteStartsForQuery(startTime, endTime) {
|
|
129
|
+
if (!startTime && !endTime) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const startMs = floorToMinute(Date.parse(startTime ?? endTime ?? new Date().toISOString()));
|
|
133
|
+
const endMs = floorToMinute(Date.parse(endTime ?? startTime ?? new Date().toISOString()));
|
|
134
|
+
const minMs = Math.min(startMs, endMs);
|
|
135
|
+
const maxMs = Math.max(startMs, endMs);
|
|
136
|
+
const minuteStarts = [];
|
|
137
|
+
for (let cursor = minMs; cursor <= maxMs; cursor += 60_000) {
|
|
138
|
+
minuteStarts.push(cursor);
|
|
139
|
+
}
|
|
140
|
+
return minuteStarts;
|
|
141
|
+
}
|
|
142
|
+
async flushExpiredChunks() {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
await Promise.all([...this.activeChunks.values()].map(async (chunk) => {
|
|
145
|
+
const intervalElapsed = now >= chunk.minuteStartMs + CHUNK_INTERVAL_MS;
|
|
146
|
+
if (intervalElapsed || chunk.sizeBytes >= CHUNK_MAX_BYTES) {
|
|
147
|
+
await this.persistChunk(chunk, true);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (chunk.dirty) {
|
|
151
|
+
await this.persistChunk(chunk, false);
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
createChunk(customerId, minuteStartMs, sequence) {
|
|
156
|
+
const chunk = {
|
|
157
|
+
customerId,
|
|
158
|
+
minuteStartMs,
|
|
159
|
+
sequence,
|
|
160
|
+
entries: [],
|
|
161
|
+
sizeBytes: 0,
|
|
162
|
+
lastPersistedEntryCount: 0,
|
|
163
|
+
dirty: false
|
|
164
|
+
};
|
|
165
|
+
this.activeChunks.set(this.chunkKeyForMap(customerId, minuteStartMs, sequence), chunk);
|
|
166
|
+
return chunk;
|
|
167
|
+
}
|
|
168
|
+
findLatestChunk(activePrefix) {
|
|
169
|
+
const matching = [...this.activeChunks.entries()]
|
|
170
|
+
.filter(([key]) => key.startsWith(activePrefix))
|
|
171
|
+
.map(([, chunk]) => chunk)
|
|
172
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
173
|
+
return matching.at(-1) ?? null;
|
|
174
|
+
}
|
|
175
|
+
activeChunkPrefix(customerId, minuteStartMs) {
|
|
176
|
+
return `${customerId}:${minuteStartMs}:`;
|
|
177
|
+
}
|
|
178
|
+
chunkKeyForMap(customerId, minuteStartMs, sequence) {
|
|
179
|
+
return `${this.activeChunkPrefix(customerId, minuteStartMs)}${sequence}`;
|
|
180
|
+
}
|
|
181
|
+
async persistChunk(chunk, finalize) {
|
|
182
|
+
if (!chunk.entries.length) {
|
|
183
|
+
if (finalize) {
|
|
184
|
+
this.activeChunks.delete(this.chunkKeyForMap(chunk.customerId, chunk.minuteStartMs, chunk.sequence));
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const key = this.objectKeyForChunk(chunk);
|
|
189
|
+
const body = `${chunk.entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
190
|
+
await this.storage.putText(key, body, "application/x-ndjson; charset=utf-8");
|
|
191
|
+
chunk.lastPersistedEntryCount = chunk.entries.length;
|
|
192
|
+
chunk.dirty = false;
|
|
193
|
+
if (finalize) {
|
|
194
|
+
this.activeChunks.delete(this.chunkKeyForMap(chunk.customerId, chunk.minuteStartMs, chunk.sequence));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
objectKeyForChunk(chunk) {
|
|
198
|
+
const startTs = Math.floor(chunk.entries[0] ? Date.parse(chunk.entries[0].createdAt) / 1000 : chunk.minuteStartMs / 1000);
|
|
199
|
+
const endTs = Math.floor(chunk.entries.at(-1) ? Date.parse(chunk.entries.at(-1).createdAt) / 1000 : chunk.minuteStartMs / 1000);
|
|
200
|
+
return `${this.minutePrefix(chunk.customerId, chunk.minuteStartMs)}chunk-${startTs}-${endTs}-${String(chunk.sequence).padStart(4, "0")}.ndjson`;
|
|
201
|
+
}
|
|
202
|
+
minutePrefix(customerId, minuteStartMs) {
|
|
203
|
+
return `${API_CALL_HISTORY_PREFIX}/customer_id=${customerId}/minute=${Math.floor(minuteStartMs / 1000)}/`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export const apiCallHistoryStore = new ApiCallHistoryStore();
|
|
207
|
+
function serializedEntrySize(entry) {
|
|
208
|
+
return Buffer.byteLength(`${JSON.stringify(entry)}\n`, "utf8");
|
|
209
|
+
}
|
|
210
|
+
function floorToMinute(timestampMs) {
|
|
211
|
+
return Math.floor(timestampMs / 60_000) * 60_000;
|
|
212
|
+
}
|
|
213
|
+
function parseChunk(content) {
|
|
214
|
+
if (!content) {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
return content
|
|
218
|
+
.split("\n")
|
|
219
|
+
.map((line) => line.trim())
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.map((line) => {
|
|
222
|
+
const parsed = JSON.parse(line);
|
|
223
|
+
return {
|
|
224
|
+
...parsed,
|
|
225
|
+
jobId: typeof parsed.jobId === "string" && parsed.jobId.trim() ? parsed.jobId.trim() : null
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function dedupeAndSort(entries) {
|
|
230
|
+
const deduped = new Map();
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
deduped.set(entry.id, entry);
|
|
233
|
+
}
|
|
234
|
+
return [...deduped.values()].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
235
|
+
}
|
|
236
|
+
function withinRange(createdAt, startTime, endTime) {
|
|
237
|
+
const value = Date.parse(createdAt);
|
|
238
|
+
if (startTime && value < Date.parse(startTime)) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (endTime && value > Date.parse(endTime)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
@@ -14,7 +14,7 @@ export class AuthService {
|
|
|
14
14
|
expiresAt: addSeconds(new Date(), 600)
|
|
15
15
|
});
|
|
16
16
|
if (config.RESEND_API_KEY) {
|
|
17
|
-
await fetch("https://api.resend.com/emails", {
|
|
17
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
18
18
|
method: "POST",
|
|
19
19
|
headers: {
|
|
20
20
|
"Content-Type": "application/json",
|
|
@@ -27,9 +27,20 @@ export class AuthService {
|
|
|
27
27
|
text: `Your login code is ${code}. It expires in 10 minutes.`
|
|
28
28
|
})
|
|
29
29
|
});
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
const body = await response.text();
|
|
32
|
+
throw new Error(`Unable to send OTP email (${response.status}): ${body || "empty response body"}`);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
delivery: "email"
|
|
36
|
+
};
|
|
30
37
|
}
|
|
31
38
|
else {
|
|
32
39
|
console.log(`[vidfarm] OTP for ${email}: ${code}`);
|
|
40
|
+
return {
|
|
41
|
+
delivery: "console",
|
|
42
|
+
code: config.isProduction ? undefined : code
|
|
43
|
+
};
|
|
33
44
|
}
|
|
34
45
|
}
|
|
35
46
|
verifyOtp(email, code, name) {
|
|
@@ -55,7 +66,8 @@ export class AuthService {
|
|
|
55
66
|
name: name ?? existing?.name ?? null,
|
|
56
67
|
defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
|
|
57
68
|
isDeveloper: existing?.isDeveloper || config.adminEmails.includes(normalizedEmail),
|
|
58
|
-
isPaidPlan: existing?.isPaidPlan ?? false
|
|
69
|
+
isPaidPlan: existing?.isPaidPlan ?? false,
|
|
70
|
+
planTier: existing?.planTier ?? "starter"
|
|
59
71
|
});
|
|
60
72
|
const rawApiKey = `vf_${createId("key")}`;
|
|
61
73
|
database.insertApiKey({
|
|
@@ -86,9 +98,12 @@ export class AuthService {
|
|
|
86
98
|
defaultWebhookUrl: row.default_webhook_url ? String(row.default_webhook_url) : null,
|
|
87
99
|
isDeveloper: Boolean(row.is_developer),
|
|
88
100
|
isPaidPlan: Boolean(row.is_paid_plan),
|
|
101
|
+
planTier: normalizePlanTier(row.plan_tier),
|
|
89
102
|
about: row.about ? String(row.about) : null,
|
|
90
103
|
groupchatUrl: row.groupchat_url ? String(row.groupchat_url) : null,
|
|
91
|
-
flockposterApiKey: row.flockposter_api_key ? String(row.flockposter_api_key) : null
|
|
104
|
+
flockposterApiKey: row.flockposter_api_key ? String(row.flockposter_api_key) : null,
|
|
105
|
+
createdAt: String(row.created_at),
|
|
106
|
+
updatedAt: String(row.updated_at)
|
|
92
107
|
};
|
|
93
108
|
}
|
|
94
109
|
verifyOtpForBrowserLogin(email, code, name) {
|
|
@@ -134,7 +149,14 @@ export class AuthService {
|
|
|
134
149
|
defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
|
|
135
150
|
isDeveloper: existing?.isDeveloper || config.adminEmails.includes(normalizedEmail),
|
|
136
151
|
isPaidPlan: true,
|
|
152
|
+
planTier: input.planTier ?? existing?.planTier ?? "starter",
|
|
137
153
|
passwordHash: hashPassword(input.password)
|
|
138
154
|
});
|
|
139
155
|
}
|
|
140
156
|
}
|
|
157
|
+
function normalizePlanTier(value) {
|
|
158
|
+
if (value === "starter" || value === "agency" || value === "standard") {
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
return "starter";
|
|
162
|
+
}
|
|
@@ -1,16 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { database } from "../db.js";
|
|
2
3
|
import { createId } from "../lib/ids.js";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
const BILLING_COST_EVENTS_PATH = "/v1/cost-events";
|
|
6
|
+
const BILLING_WALLET_FUNDING_INTENTS_PATH = "/v1/wallet-funding-intents";
|
|
7
|
+
const BILLING_WALLET_FUNDING_CONFIRMED_PATH = "/v1/wallet-funding-confirmed";
|
|
8
|
+
const PAID_SIGNUP_BONUS_GRANT_TYPE = "paid_signup_bonus";
|
|
9
|
+
const PAID_SIGNUP_BONUS_USD = 5;
|
|
3
10
|
export class BillingService {
|
|
11
|
+
async getWalletSummary(customerId) {
|
|
12
|
+
if (!config.VIDFARM_BILLING_URL || !config.VIDFARM_BILLING_SECRET) {
|
|
13
|
+
throw new Error("Billing service is not configured.");
|
|
14
|
+
}
|
|
15
|
+
const response = await fetch(new URL(`/v1/wallets/${encodeURIComponent(customerId)}`, config.VIDFARM_BILLING_URL), {
|
|
16
|
+
headers: {
|
|
17
|
+
"x-vidfarm-billing-secret": config.VIDFARM_BILLING_SECRET
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
const body = await response.text().catch(() => "");
|
|
22
|
+
throw new Error(`Billing wallet lookup failed (${response.status}): ${body || response.statusText}`);
|
|
23
|
+
}
|
|
24
|
+
const payload = await response.json();
|
|
25
|
+
return {
|
|
26
|
+
currency: typeof payload.currency === "string" ? payload.currency : "USD",
|
|
27
|
+
balanceUsd: Number(payload.balance_usd ?? 0),
|
|
28
|
+
balanceMicrosUsd: Number(payload.balance_micros_usd ?? 0)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async getJobBillingSummary(input) {
|
|
32
|
+
if (!config.VIDFARM_BILLING_URL || !config.VIDFARM_BILLING_SECRET) {
|
|
33
|
+
return {
|
|
34
|
+
currency: "USD",
|
|
35
|
+
eventCount: 0,
|
|
36
|
+
totalBaseCostUsd: 0,
|
|
37
|
+
totalChargeUsd: 0,
|
|
38
|
+
unavailableReason: "Billing service is not configured."
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const url = new URL(`/v1/jobs/${encodeURIComponent(input.jobId)}/billing`, config.VIDFARM_BILLING_URL);
|
|
42
|
+
url.searchParams.set("customer_id", input.customerId);
|
|
43
|
+
if (input.templateId) {
|
|
44
|
+
url.searchParams.set("template_id", input.templateId);
|
|
45
|
+
}
|
|
46
|
+
const response = await fetch(url, {
|
|
47
|
+
headers: {
|
|
48
|
+
"x-vidfarm-billing-secret": config.VIDFARM_BILLING_SECRET
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const body = await response.text().catch(() => "");
|
|
53
|
+
throw new Error(`Billing job summary lookup failed (${response.status}): ${body || response.statusText}`);
|
|
54
|
+
}
|
|
55
|
+
const payload = await response.json();
|
|
56
|
+
return {
|
|
57
|
+
currency: typeof payload.currency === "string" ? payload.currency : "USD",
|
|
58
|
+
eventCount: Number(payload.event_count ?? 0),
|
|
59
|
+
totalBaseCostUsd: Number(payload.total_base_cost_usd ?? 0),
|
|
60
|
+
totalChargeUsd: Number(payload.total_charge_usd ?? 0),
|
|
61
|
+
unavailableReason: null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
4
64
|
async record(input) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
65
|
+
const normalizedCostCenterSlug = resolveCostCenterSlug(input);
|
|
66
|
+
const normalizedIdempotencyKey = resolveIdempotencyKey(input, normalizedCostCenterSlug);
|
|
67
|
+
const remoteEventCostCenterSlug = normalizedCostCenterSlug ?? null;
|
|
68
|
+
if (!remoteEventCostCenterSlug) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!config.VIDFARM_BILLING_URL || !config.VIDFARM_BILLING_SECRET) {
|
|
72
|
+
throw new Error(`Billing URL/secret missing for cost center ${remoteEventCostCenterSlug}.`);
|
|
73
|
+
}
|
|
74
|
+
const response = await fetch(new URL(BILLING_COST_EVENTS_PATH, config.VIDFARM_BILLING_URL), {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"content-type": "application/json",
|
|
78
|
+
"x-vidfarm-billing-secret": config.VIDFARM_BILLING_SECRET
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
idempotency_key: normalizedIdempotencyKey ?? `${remoteEventCostCenterSlug}:${input.jobId}:${input.templateId}:${input.type}`,
|
|
82
|
+
customer_id: input.customerId,
|
|
83
|
+
job_id: input.jobId,
|
|
84
|
+
tracer: input.tracer,
|
|
85
|
+
cost_center_slug: remoteEventCostCenterSlug,
|
|
86
|
+
base_cost_usd: input.costUsd,
|
|
87
|
+
metadata: {
|
|
88
|
+
...input.metadata,
|
|
89
|
+
template_id: input.templateId,
|
|
90
|
+
local_charge_usd: input.chargeUsd
|
|
91
|
+
},
|
|
92
|
+
occurred_at_ms: input.occurredAtMs ?? Date.now()
|
|
93
|
+
})
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const body = await response.text().catch(() => "");
|
|
97
|
+
throw new Error(`Billing service rejected cost event (${response.status}): ${body || response.statusText}`);
|
|
98
|
+
}
|
|
99
|
+
await response.json().catch(() => ({}));
|
|
100
|
+
}
|
|
101
|
+
async grantPaidSignupBonus(customerId, metadata) {
|
|
102
|
+
const customer = database.getCustomerById(customerId);
|
|
103
|
+
if (!customer) {
|
|
104
|
+
throw new Error("Customer not found.");
|
|
105
|
+
}
|
|
106
|
+
if (!customer.isPaidPlan) {
|
|
107
|
+
return { granted: false, reason: "customer_not_paid" };
|
|
108
|
+
}
|
|
109
|
+
if (database.hasCustomerCreditGrant(customerId, PAID_SIGNUP_BONUS_GRANT_TYPE)) {
|
|
110
|
+
return { granted: false, reason: "already_granted" };
|
|
111
|
+
}
|
|
112
|
+
if (!config.VIDFARM_BILLING_URL || !config.VIDFARM_BILLING_SECRET) {
|
|
113
|
+
throw new Error("Billing service is not configured.");
|
|
114
|
+
}
|
|
115
|
+
const clientReferenceId = buildPaidSignupBonusClientReferenceId(customerId);
|
|
116
|
+
const stripeEventId = `${PAID_SIGNUP_BONUS_GRANT_TYPE}:${customerId}`;
|
|
117
|
+
const intentResponse = await fetch(new URL(BILLING_WALLET_FUNDING_INTENTS_PATH, config.VIDFARM_BILLING_URL), {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
"content-type": "application/json",
|
|
121
|
+
"x-vidfarm-billing-secret": config.VIDFARM_BILLING_SECRET
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
customer_id: customerId,
|
|
125
|
+
client_reference_id: clientReferenceId,
|
|
126
|
+
occurred_at_ms: Date.now(),
|
|
127
|
+
metadata: {
|
|
128
|
+
...(metadata ?? {}),
|
|
129
|
+
grant_type: PAID_SIGNUP_BONUS_GRANT_TYPE
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
});
|
|
133
|
+
if (!intentResponse.ok) {
|
|
134
|
+
const body = await intentResponse.text().catch(() => "");
|
|
135
|
+
if (!/TransactionCanceledException|ConditionalCheckFailed|duplicate/i.test(body)) {
|
|
136
|
+
throw new Error(`Billing service rejected wallet funding intent (${intentResponse.status}): ${body || intentResponse.statusText}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const response = await fetch(new URL(BILLING_WALLET_FUNDING_CONFIRMED_PATH, config.VIDFARM_BILLING_URL), {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
"content-type": "application/json",
|
|
143
|
+
"x-vidfarm-billing-secret": config.VIDFARM_BILLING_SECRET
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
client_reference_id: clientReferenceId,
|
|
147
|
+
amount_usd: PAID_SIGNUP_BONUS_USD,
|
|
148
|
+
stripe_event_id: stripeEventId,
|
|
149
|
+
occurred_at_ms: Date.now(),
|
|
150
|
+
metadata: {
|
|
151
|
+
...(metadata ?? {}),
|
|
152
|
+
grant_type: PAID_SIGNUP_BONUS_GRANT_TYPE
|
|
153
|
+
}
|
|
154
|
+
})
|
|
14
155
|
});
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
const body = await response.text().catch(() => "");
|
|
158
|
+
throw new Error(`Billing service rejected signup wallet funding (${response.status}): ${body || response.statusText}`);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
database.insertCustomerCreditGrant({
|
|
162
|
+
id: createId("grant"),
|
|
163
|
+
customerId,
|
|
164
|
+
grantType: PAID_SIGNUP_BONUS_GRANT_TYPE,
|
|
165
|
+
amountUsd: PAID_SIGNUP_BONUS_USD,
|
|
166
|
+
metadata: metadata ?? {}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (!isUniqueConstraintError(error)) {
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
granted: true,
|
|
176
|
+
reason: "granted",
|
|
177
|
+
amountUsd: PAID_SIGNUP_BONUS_USD,
|
|
178
|
+
grantType: PAID_SIGNUP_BONUS_GRANT_TYPE
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function resolveCostCenterSlug(input) {
|
|
183
|
+
if (input.costCenterSlug?.trim()) {
|
|
184
|
+
const explicitSlug = input.costCenterSlug.trim();
|
|
185
|
+
return explicitSlug === "remotion_lambda" ? explicitSlug : null;
|
|
186
|
+
}
|
|
187
|
+
if (input.type === "render" && input.metadata?.mode === "lambda") {
|
|
188
|
+
return "remotion_lambda";
|
|
15
189
|
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
function resolveIdempotencyKey(input, costCenterSlug) {
|
|
193
|
+
if (input.idempotencyKey?.trim()) {
|
|
194
|
+
return input.idempotencyKey.trim();
|
|
195
|
+
}
|
|
196
|
+
const renderId = typeof input.metadata?.render_id === "string"
|
|
197
|
+
? input.metadata.render_id
|
|
198
|
+
: typeof input.metadata?.renderId === "string"
|
|
199
|
+
? input.metadata.renderId
|
|
200
|
+
: null;
|
|
201
|
+
if (costCenterSlug === "remotion_lambda" && renderId) {
|
|
202
|
+
return `remotion_lambda:${renderId}`;
|
|
203
|
+
}
|
|
204
|
+
return buildBillingEventFingerprint(input, costCenterSlug);
|
|
205
|
+
}
|
|
206
|
+
function buildBillingEventFingerprint(input, costCenterSlug) {
|
|
207
|
+
const fingerprintPayload = {
|
|
208
|
+
customerId: input.customerId,
|
|
209
|
+
jobId: input.jobId,
|
|
210
|
+
templateId: input.templateId,
|
|
211
|
+
tracer: input.tracer ?? null,
|
|
212
|
+
type: input.type,
|
|
213
|
+
costCenterSlug,
|
|
214
|
+
costUsd: roundUsd(input.costUsd),
|
|
215
|
+
chargeUsd: input.chargeUsd == null ? null : roundUsd(input.chargeUsd),
|
|
216
|
+
occurredAtMs: input.occurredAtMs ?? null,
|
|
217
|
+
metadata: sortJsonValue(input.metadata ?? {})
|
|
218
|
+
};
|
|
219
|
+
const digest = createHash("sha256")
|
|
220
|
+
.update(JSON.stringify(fingerprintPayload))
|
|
221
|
+
.digest("hex")
|
|
222
|
+
.slice(0, 24);
|
|
223
|
+
return `${costCenterSlug ?? input.type}:${input.jobId}:${digest}`;
|
|
224
|
+
}
|
|
225
|
+
function sortJsonValue(value) {
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
return value.map(sortJsonValue);
|
|
228
|
+
}
|
|
229
|
+
if (value && typeof value === "object") {
|
|
230
|
+
return Object.entries(value)
|
|
231
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
232
|
+
.reduce((acc, [key, nestedValue]) => {
|
|
233
|
+
acc[key] = sortJsonValue(nestedValue);
|
|
234
|
+
return acc;
|
|
235
|
+
}, {});
|
|
236
|
+
}
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
function roundUsd(value) {
|
|
240
|
+
return Number(value.toFixed(6));
|
|
241
|
+
}
|
|
242
|
+
function isUniqueConstraintError(error) {
|
|
243
|
+
return Boolean(error instanceof Error && /unique constraint/i.test(error.message));
|
|
244
|
+
}
|
|
245
|
+
function buildPaidSignupBonusClientReferenceId(customerId) {
|
|
246
|
+
const digest = createHash("sha256").update(`${PAID_SIGNUP_BONUS_GRANT_TYPE}:${customerId}`).digest("hex");
|
|
247
|
+
const part1 = digest.slice(0, 8);
|
|
248
|
+
const part2 = digest.slice(8, 12);
|
|
249
|
+
const part3 = `4${digest.slice(13, 16)}`;
|
|
250
|
+
const variantNibble = (8 + (Number.parseInt(digest.slice(16, 17), 16) % 4)).toString(16);
|
|
251
|
+
const part4 = `${variantNibble}${digest.slice(17, 20)}`;
|
|
252
|
+
const part5 = digest.slice(20, 32);
|
|
253
|
+
return `vidfarm_wallet_topup_${part1}-${part2}-${part3}-${part4}-${part5}`;
|
|
16
254
|
}
|
|
255
|
+
export { PAID_SIGNUP_BONUS_GRANT_TYPE, PAID_SIGNUP_BONUS_USD, resolveCostCenterSlug, resolveIdempotencyKey };
|