@mevdragon/vidfarm-devcli 0.2.1 → 0.2.3
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 +6 -39
- package/GETTING_STARTED.developers.md +87 -0
- package/README.md +94 -238
- package/SKILL.developer.md +430 -104
- package/dist/src/account-pages.js +1 -1
- package/dist/src/app.js +93 -5
- package/dist/src/cli.js +456 -8
- package/dist/src/config.js +3 -2
- package/dist/src/context.js +30 -11
- package/dist/src/db.js +2 -57
- package/dist/src/dev-app.js +0 -1
- package/dist/src/index.js +4 -2
- package/dist/src/lib/template-paths.js +21 -0
- package/dist/src/runtime.js +3 -1
- package/dist/src/services/auth.js +4 -4
- package/dist/src/services/job-logs.js +186 -0
- package/dist/src/services/jobs.js +3 -2
- package/dist/src/services/providers.js +14 -6
- package/dist/src/services/storage.js +85 -2
- package/dist/src/services/template-sources.js +29 -3
- package/dist/templates/template_0000/src/lib/images.js +46 -86
- package/dist/templates/template_0000/src/template.js +277 -53
- package/package.json +5 -6
- package/templates/template_0000/README.md +8 -52
- package/templates/template_0000/SKILL.md +35 -3
- package/templates/template_0000/package.json +3 -6
- package/templates/template_0000/src/lib/images.js +46 -86
- package/templates/template_0000/src/lib/images.ts +55 -98
- package/templates/template_0000/src/template-dna.js +9 -0
- package/templates/template_0000/src/template.js +523 -199
- package/templates/template_0000/src/template.ts +356 -61
- package/templates/template_0000/template.config.json +7 -12
- package/AWS_REMOTION_HANDOFF.md +0 -311
- package/PLATFORM_SPEC.md +0 -1039
- package/SKILL.director.md +0 -599
- package/dist/infra/cdk/bin/vidfarm-prod.js +0 -59
- package/dist/infra/cdk/lib/vidfarm-prod-stack.js +0 -212
- package/templates/template_0000/package-lock.json +0 -5505
- package/templates/template_0000/scripts/create-site.mjs +0 -27
- package/templates/template_0000/scripts/render-cloud.mjs +0 -72
package/dist/src/context.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { config } from "./config.js";
|
|
2
2
|
import { database } from "./db.js";
|
|
3
3
|
import { createId } from "./lib/ids.js";
|
|
4
|
+
import { jobLogStore } from "./services/job-logs.js";
|
|
4
5
|
export function createTemplateJobContext(input) {
|
|
5
6
|
const templateConfig = database.getTemplateConfig(input.customer.id, input.template.id);
|
|
6
7
|
const prefix = input.storage.templateJobPrefix(input.template.id, input.customer.id, input.job.id);
|
|
8
|
+
const buildArtifactUrl = (storageKey, fallbackUrl) => {
|
|
9
|
+
if (config.STORAGE_DRIVER !== "s3") {
|
|
10
|
+
return fallbackUrl;
|
|
11
|
+
}
|
|
12
|
+
return `${config.PUBLIC_BASE_URL}/template-media?key=${encodeURIComponent(storageKey)}`;
|
|
13
|
+
};
|
|
7
14
|
return {
|
|
8
15
|
env: config.isProduction ? "production" : "development",
|
|
9
16
|
customer: input.customer,
|
|
10
17
|
templateConfig,
|
|
11
18
|
logger: {
|
|
12
19
|
debug(message, metadata = {}) {
|
|
13
|
-
|
|
20
|
+
jobLogStore.addEvent({
|
|
14
21
|
id: createId("evt"),
|
|
15
22
|
jobId: input.job.id,
|
|
16
23
|
level: "debug",
|
|
@@ -21,7 +28,7 @@ export function createTemplateJobContext(input) {
|
|
|
21
28
|
});
|
|
22
29
|
},
|
|
23
30
|
info(message, metadata = {}) {
|
|
24
|
-
|
|
31
|
+
jobLogStore.addEvent({
|
|
25
32
|
id: createId("evt"),
|
|
26
33
|
jobId: input.job.id,
|
|
27
34
|
level: "info",
|
|
@@ -32,7 +39,7 @@ export function createTemplateJobContext(input) {
|
|
|
32
39
|
});
|
|
33
40
|
},
|
|
34
41
|
warn(message, metadata = {}) {
|
|
35
|
-
|
|
42
|
+
jobLogStore.addEvent({
|
|
36
43
|
id: createId("evt"),
|
|
37
44
|
jobId: input.job.id,
|
|
38
45
|
level: "warn",
|
|
@@ -43,7 +50,7 @@ export function createTemplateJobContext(input) {
|
|
|
43
50
|
});
|
|
44
51
|
},
|
|
45
52
|
error(message, metadata = {}) {
|
|
46
|
-
|
|
53
|
+
jobLogStore.addEvent({
|
|
47
54
|
id: createId("evt"),
|
|
48
55
|
jobId: input.job.id,
|
|
49
56
|
level: "error",
|
|
@@ -54,7 +61,7 @@ export function createTemplateJobContext(input) {
|
|
|
54
61
|
});
|
|
55
62
|
},
|
|
56
63
|
progress(progress, message, metadata = {}) {
|
|
57
|
-
|
|
64
|
+
jobLogStore.addEvent({
|
|
58
65
|
id: createId("evt"),
|
|
59
66
|
jobId: input.job.id,
|
|
60
67
|
level: "info",
|
|
@@ -88,7 +95,8 @@ export function createTemplateJobContext(input) {
|
|
|
88
95
|
storage: {
|
|
89
96
|
async putJson(key, value) {
|
|
90
97
|
const stored = await input.storage.putJson(`${prefix}/${key}`, value);
|
|
91
|
-
|
|
98
|
+
const artifactUrl = buildArtifactUrl(stored.key, stored.url);
|
|
99
|
+
insertArtifact("json", stored.key, artifactUrl, {});
|
|
92
100
|
await input.billing.record({
|
|
93
101
|
customerId: input.customer.id,
|
|
94
102
|
jobId: input.job.id,
|
|
@@ -98,11 +106,15 @@ export function createTemplateJobContext(input) {
|
|
|
98
106
|
chargeUsd: 0.01,
|
|
99
107
|
metadata: { key: stored.key }
|
|
100
108
|
});
|
|
101
|
-
return
|
|
109
|
+
return {
|
|
110
|
+
...stored,
|
|
111
|
+
url: artifactUrl
|
|
112
|
+
};
|
|
102
113
|
},
|
|
103
114
|
async putText(key, value, contentType = "text/plain; charset=utf-8") {
|
|
104
115
|
const stored = await input.storage.putText(`${prefix}/${key}`, value, contentType);
|
|
105
|
-
|
|
116
|
+
const artifactUrl = buildArtifactUrl(stored.key, stored.url);
|
|
117
|
+
insertArtifact("text", stored.key, artifactUrl, { contentType });
|
|
106
118
|
await input.billing.record({
|
|
107
119
|
customerId: input.customer.id,
|
|
108
120
|
jobId: input.job.id,
|
|
@@ -112,11 +124,15 @@ export function createTemplateJobContext(input) {
|
|
|
112
124
|
chargeUsd: 0.01,
|
|
113
125
|
metadata: { key: stored.key }
|
|
114
126
|
});
|
|
115
|
-
return
|
|
127
|
+
return {
|
|
128
|
+
...stored,
|
|
129
|
+
url: artifactUrl
|
|
130
|
+
};
|
|
116
131
|
},
|
|
117
132
|
async putBuffer(key, value, options = {}) {
|
|
118
133
|
const stored = await input.storage.putBuffer(`${prefix}/${key}`, value, options.contentType);
|
|
119
|
-
|
|
134
|
+
const artifactUrl = buildArtifactUrl(stored.key, stored.url);
|
|
135
|
+
insertArtifact(options.kind ?? "binary", stored.key, artifactUrl, {
|
|
120
136
|
...(options.metadata ?? {}),
|
|
121
137
|
contentType: options.contentType ?? null
|
|
122
138
|
});
|
|
@@ -129,7 +145,10 @@ export function createTemplateJobContext(input) {
|
|
|
129
145
|
chargeUsd: 0.01,
|
|
130
146
|
metadata: { key: stored.key }
|
|
131
147
|
});
|
|
132
|
-
return
|
|
148
|
+
return {
|
|
149
|
+
...stored,
|
|
150
|
+
url: artifactUrl
|
|
151
|
+
};
|
|
133
152
|
},
|
|
134
153
|
getPublicUrl(key) {
|
|
135
154
|
return input.storage.getPublicUrl(`${prefix}/${key}`);
|
package/dist/src/db.js
CHANGED
|
@@ -116,19 +116,6 @@ create table if not exists jobs (
|
|
|
116
116
|
|
|
117
117
|
create index if not exists idx_jobs_runnable on jobs(status, run_after, priority, created_at);
|
|
118
118
|
|
|
119
|
-
create table if not exists job_events (
|
|
120
|
-
id text primary key,
|
|
121
|
-
job_id text not null references jobs(id) on delete cascade,
|
|
122
|
-
level text not null,
|
|
123
|
-
message text not null,
|
|
124
|
-
metadata_json text not null,
|
|
125
|
-
progress real,
|
|
126
|
-
artifact_key text,
|
|
127
|
-
created_at text not null
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
create index if not exists idx_job_events_job_time on job_events(job_id, created_at);
|
|
131
|
-
|
|
132
119
|
create table if not exists artifacts (
|
|
133
120
|
id text primary key,
|
|
134
121
|
job_id text not null references jobs(id) on delete cascade,
|
|
@@ -248,6 +235,8 @@ db.exec(`
|
|
|
248
235
|
where slug_id is null or trim(slug_id) = '';
|
|
249
236
|
`);
|
|
250
237
|
db.exec(`create unique index if not exists idx_template_sources_slug_id on template_sources(slug_id);`);
|
|
238
|
+
db.exec(`drop index if exists idx_job_events_job_time;`);
|
|
239
|
+
db.exec(`drop table if exists job_events;`);
|
|
251
240
|
function mapJob(row) {
|
|
252
241
|
return {
|
|
253
242
|
id: String(row.id),
|
|
@@ -634,50 +623,6 @@ export const database = {
|
|
|
634
623
|
`).all(...values);
|
|
635
624
|
return rows.map(mapJob);
|
|
636
625
|
},
|
|
637
|
-
addJobEvent(event) {
|
|
638
|
-
db.prepare(`
|
|
639
|
-
insert into job_events (id, job_id, level, message, metadata_json, progress, artifact_key, created_at)
|
|
640
|
-
values (@id, @job_id, @level, @message, @metadata_json, @progress, @artifact_key, @created_at)
|
|
641
|
-
`).run({
|
|
642
|
-
id: event.id,
|
|
643
|
-
job_id: event.jobId,
|
|
644
|
-
level: event.level,
|
|
645
|
-
message: event.message,
|
|
646
|
-
metadata_json: stringifyJson(event.metadata),
|
|
647
|
-
progress: event.progress,
|
|
648
|
-
artifact_key: event.artifactKey,
|
|
649
|
-
created_at: nowIso()
|
|
650
|
-
});
|
|
651
|
-
},
|
|
652
|
-
getJobEvents(input) {
|
|
653
|
-
const filters = ["job_id = ?"];
|
|
654
|
-
const values = [input.jobId];
|
|
655
|
-
if (input.startTime) {
|
|
656
|
-
filters.push("created_at >= ?");
|
|
657
|
-
values.push(input.startTime);
|
|
658
|
-
}
|
|
659
|
-
if (input.endTime) {
|
|
660
|
-
filters.push("created_at <= ?");
|
|
661
|
-
values.push(input.endTime);
|
|
662
|
-
}
|
|
663
|
-
values.push(Math.max(1, Math.min(input.limit ?? 100, 500)));
|
|
664
|
-
const rows = db.prepare(`
|
|
665
|
-
select * from job_events
|
|
666
|
-
where ${filters.join(" and ")}
|
|
667
|
-
order by created_at asc
|
|
668
|
-
limit ?
|
|
669
|
-
`).all(...values);
|
|
670
|
-
return rows.map((row) => ({
|
|
671
|
-
id: String(row.id),
|
|
672
|
-
jobId: String(row.job_id),
|
|
673
|
-
level: row.level,
|
|
674
|
-
message: String(row.message),
|
|
675
|
-
metadata: parseJson(row.metadata_json, {}),
|
|
676
|
-
progress: row.progress === null ? null : Number(row.progress),
|
|
677
|
-
artifactKey: row.artifact_key ? String(row.artifact_key) : null,
|
|
678
|
-
createdAt: String(row.created_at)
|
|
679
|
-
}));
|
|
680
|
-
},
|
|
681
626
|
insertArtifact(record) {
|
|
682
627
|
db.prepare(`
|
|
683
628
|
insert into artifacts (id, job_id, customer_id, template_id, kind, storage_key, public_url, metadata_json, created_at)
|
package/dist/src/dev-app.js
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -3,8 +3,10 @@ void (async () => {
|
|
|
3
3
|
const runtime = await startRuntime();
|
|
4
4
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
5
5
|
process.on(signal, () => {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
void (async () => {
|
|
7
|
+
await runtime.shutdown();
|
|
8
|
+
process.exit(0);
|
|
9
|
+
})();
|
|
8
10
|
});
|
|
9
11
|
}
|
|
10
12
|
})().catch((error) => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export const TEMPLATE_FOLDER_PREFIX = "vidfarm_template_";
|
|
3
|
+
export function assertTemplateFolderNameHasPrefix(folderName) {
|
|
4
|
+
if (!folderName.startsWith(TEMPLATE_FOLDER_PREFIX)) {
|
|
5
|
+
throw new Error(`Template folder name must start with ${TEMPLATE_FOLDER_PREFIX}. Received: ${folderName}`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function deriveTemplateRootDirFromModulePath(templateModulePath) {
|
|
9
|
+
const normalizedModulePath = templateModulePath.replace(/\\/g, "/");
|
|
10
|
+
const templateSourceDir = path.posix.dirname(normalizedModulePath);
|
|
11
|
+
const templateRootDir = path.posix.dirname(templateSourceDir);
|
|
12
|
+
if (templateRootDir === "." || templateRootDir === "") {
|
|
13
|
+
throw new Error("template_module_path must point to a template inside a folder such as templates/vidfarm_template_example/src/template.ts.");
|
|
14
|
+
}
|
|
15
|
+
const folderName = path.posix.basename(templateRootDir);
|
|
16
|
+
assertTemplateFolderNameHasPrefix(folderName);
|
|
17
|
+
return templateRootDir;
|
|
18
|
+
}
|
|
19
|
+
export function defaultSkillPathForTemplateModule(templateModulePath) {
|
|
20
|
+
return path.posix.join(deriveTemplateRootDirFromModulePath(templateModulePath), "SKILL.md");
|
|
21
|
+
}
|
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 { jobLogStore } from "./services/job-logs.js";
|
|
5
6
|
import { Worker } from "./worker.js";
|
|
6
7
|
export async function startRuntime() {
|
|
7
8
|
await templateRegistry.ensureInitialized();
|
|
@@ -13,8 +14,9 @@ export async function startRuntime() {
|
|
|
13
14
|
}, (info) => {
|
|
14
15
|
console.log(`[vidfarm] listening on http://localhost:${info.port}`);
|
|
15
16
|
});
|
|
16
|
-
const shutdown = () => {
|
|
17
|
+
const shutdown = async () => {
|
|
17
18
|
worker.stop();
|
|
19
|
+
await jobLogStore.shutdown();
|
|
18
20
|
server.close();
|
|
19
21
|
};
|
|
20
22
|
return { server, worker, shutdown };
|
|
@@ -69,12 +69,12 @@ export class AuthService {
|
|
|
69
69
|
});
|
|
70
70
|
return { customer, apiKey: rawApiKey };
|
|
71
71
|
}
|
|
72
|
-
authenticate(
|
|
73
|
-
if (!
|
|
74
|
-
throw new Error("Missing vidfarm-
|
|
72
|
+
authenticate(apiKey) {
|
|
73
|
+
if (!apiKey) {
|
|
74
|
+
throw new Error("Missing vidfarm-api-key header.");
|
|
75
75
|
}
|
|
76
76
|
const row = database.findApiKeyByHash(hashSecret(apiKey + config.API_KEY_SALT));
|
|
77
|
-
if (!row
|
|
77
|
+
if (!row) {
|
|
78
78
|
throw new Error("Invalid API credentials.");
|
|
79
79
|
}
|
|
80
80
|
if (!Boolean(row.is_paid_plan)) {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { StorageService } from "./storage.js";
|
|
2
|
+
const JOB_LOG_PREFIX = "job-logs";
|
|
3
|
+
const JOB_LOG_CHUNK_INTERVAL_MS = 60_000;
|
|
4
|
+
const JOB_LOG_CHUNK_MAX_BYTES = 1024 * 1024;
|
|
5
|
+
const JOB_LOG_FLUSH_INTERVAL_MS = 5_000;
|
|
6
|
+
export class JobLogStore {
|
|
7
|
+
storage = new StorageService();
|
|
8
|
+
activeChunks = new Map();
|
|
9
|
+
flushTimer = setInterval(() => {
|
|
10
|
+
void this.flushExpiredChunks();
|
|
11
|
+
}, JOB_LOG_FLUSH_INTERVAL_MS);
|
|
12
|
+
constructor() {
|
|
13
|
+
this.flushTimer.unref();
|
|
14
|
+
}
|
|
15
|
+
addEvent(event) {
|
|
16
|
+
const normalized = {
|
|
17
|
+
...event,
|
|
18
|
+
createdAt: event.createdAt ?? new Date().toISOString()
|
|
19
|
+
};
|
|
20
|
+
const minuteStartMs = floorToMinute(Date.parse(normalized.createdAt));
|
|
21
|
+
const activePrefix = this.activeChunkPrefix(normalized.jobId, minuteStartMs);
|
|
22
|
+
const current = this.findLatestChunk(activePrefix);
|
|
23
|
+
const eventBytes = serializedEventSize(normalized);
|
|
24
|
+
const chunk = current && current.sizeBytes + eventBytes <= JOB_LOG_CHUNK_MAX_BYTES
|
|
25
|
+
? current
|
|
26
|
+
: this.createChunk(normalized.jobId, minuteStartMs, current ? current.sequence + 1 : 0);
|
|
27
|
+
chunk.events.push(normalized);
|
|
28
|
+
chunk.sizeBytes += eventBytes;
|
|
29
|
+
chunk.dirty = true;
|
|
30
|
+
if (chunk.sizeBytes >= JOB_LOG_CHUNK_MAX_BYTES) {
|
|
31
|
+
void this.persistChunk(chunk, true);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async listEvents(input) {
|
|
35
|
+
const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
|
|
36
|
+
const persisted = await this.readPersistedEvents(input);
|
|
37
|
+
const buffered = this.readBufferedEvents(input);
|
|
38
|
+
const merged = dedupeAndSort([...persisted, ...buffered]);
|
|
39
|
+
return merged.slice(-limit);
|
|
40
|
+
}
|
|
41
|
+
async shutdown() {
|
|
42
|
+
clearInterval(this.flushTimer);
|
|
43
|
+
await Promise.all([...this.activeChunks.values()].map((chunk) => this.persistChunk(chunk, true)));
|
|
44
|
+
}
|
|
45
|
+
async readPersistedEvents(input) {
|
|
46
|
+
const keys = await this.listRelevantKeys(input);
|
|
47
|
+
const events = await Promise.all(keys.map(async (key) => parseChunk(await this.storage.readText(key))));
|
|
48
|
+
return events
|
|
49
|
+
.flat()
|
|
50
|
+
.filter((event) => event.jobId === input.jobId)
|
|
51
|
+
.filter((event) => withinRange(event.createdAt, input.startTime, input.endTime));
|
|
52
|
+
}
|
|
53
|
+
readBufferedEvents(input) {
|
|
54
|
+
const buffered = [];
|
|
55
|
+
for (const chunk of this.activeChunks.values()) {
|
|
56
|
+
if (chunk.jobId !== input.jobId) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const event of chunk.events.slice(chunk.lastPersistedEventCount)) {
|
|
60
|
+
if (withinRange(event.createdAt, input.startTime, input.endTime)) {
|
|
61
|
+
buffered.push(event);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return buffered;
|
|
66
|
+
}
|
|
67
|
+
async listRelevantKeys(input) {
|
|
68
|
+
const minuteStarts = this.minuteStartsForQuery(input.startTime, input.endTime);
|
|
69
|
+
if (minuteStarts === null) {
|
|
70
|
+
return this.storage.listKeys(`${JOB_LOG_PREFIX}/job_id=${input.jobId}/`);
|
|
71
|
+
}
|
|
72
|
+
const nested = await Promise.all(minuteStarts.map((minuteStartMs) => (this.storage.listKeys(this.minutePrefix(input.jobId, minuteStartMs)))));
|
|
73
|
+
return nested.flat();
|
|
74
|
+
}
|
|
75
|
+
minuteStartsForQuery(startTime, endTime) {
|
|
76
|
+
if (!startTime && !endTime) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const startMs = floorToMinute(Date.parse(startTime ?? endTime ?? new Date().toISOString()));
|
|
80
|
+
const endMs = floorToMinute(Date.parse(endTime ?? startTime ?? new Date().toISOString()));
|
|
81
|
+
const minMs = Math.min(startMs, endMs);
|
|
82
|
+
const maxMs = Math.max(startMs, endMs);
|
|
83
|
+
const minuteStarts = [];
|
|
84
|
+
for (let cursor = minMs; cursor <= maxMs; cursor += 60_000) {
|
|
85
|
+
minuteStarts.push(cursor);
|
|
86
|
+
}
|
|
87
|
+
return minuteStarts;
|
|
88
|
+
}
|
|
89
|
+
async flushExpiredChunks() {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
await Promise.all([...this.activeChunks.values()].map(async (chunk) => {
|
|
92
|
+
const intervalElapsed = now >= chunk.minuteStartMs + JOB_LOG_CHUNK_INTERVAL_MS;
|
|
93
|
+
if (intervalElapsed || chunk.sizeBytes >= JOB_LOG_CHUNK_MAX_BYTES) {
|
|
94
|
+
await this.persistChunk(chunk, true);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (chunk.dirty) {
|
|
98
|
+
await this.persistChunk(chunk, false);
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
createChunk(jobId, minuteStartMs, sequence) {
|
|
103
|
+
const chunk = {
|
|
104
|
+
jobId,
|
|
105
|
+
minuteStartMs,
|
|
106
|
+
sequence,
|
|
107
|
+
events: [],
|
|
108
|
+
sizeBytes: 0,
|
|
109
|
+
lastPersistedEventCount: 0,
|
|
110
|
+
dirty: false
|
|
111
|
+
};
|
|
112
|
+
this.activeChunks.set(this.chunkKeyForMap(jobId, minuteStartMs, sequence), chunk);
|
|
113
|
+
return chunk;
|
|
114
|
+
}
|
|
115
|
+
findLatestChunk(activePrefix) {
|
|
116
|
+
const matching = [...this.activeChunks.entries()]
|
|
117
|
+
.filter(([key]) => key.startsWith(activePrefix))
|
|
118
|
+
.map(([, chunk]) => chunk)
|
|
119
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
120
|
+
return matching.at(-1) ?? null;
|
|
121
|
+
}
|
|
122
|
+
activeChunkPrefix(jobId, minuteStartMs) {
|
|
123
|
+
return `${jobId}:${minuteStartMs}:`;
|
|
124
|
+
}
|
|
125
|
+
chunkKeyForMap(jobId, minuteStartMs, sequence) {
|
|
126
|
+
return `${this.activeChunkPrefix(jobId, minuteStartMs)}${sequence}`;
|
|
127
|
+
}
|
|
128
|
+
async persistChunk(chunk, finalize) {
|
|
129
|
+
if (!chunk.events.length) {
|
|
130
|
+
if (finalize) {
|
|
131
|
+
this.activeChunks.delete(this.chunkKeyForMap(chunk.jobId, chunk.minuteStartMs, chunk.sequence));
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const key = this.objectKeyForChunk(chunk);
|
|
136
|
+
const body = `${chunk.events.map((event) => JSON.stringify(event)).join("\n")}\n`;
|
|
137
|
+
await this.storage.putText(key, body, "application/x-ndjson; charset=utf-8");
|
|
138
|
+
chunk.lastPersistedEventCount = chunk.events.length;
|
|
139
|
+
chunk.dirty = false;
|
|
140
|
+
if (finalize) {
|
|
141
|
+
this.activeChunks.delete(this.chunkKeyForMap(chunk.jobId, chunk.minuteStartMs, chunk.sequence));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
objectKeyForChunk(chunk) {
|
|
145
|
+
const startTs = Math.floor(chunk.events[0] ? Date.parse(chunk.events[0].createdAt) / 1000 : chunk.minuteStartMs / 1000);
|
|
146
|
+
const endTs = Math.floor(chunk.events.at(-1) ? Date.parse(chunk.events.at(-1).createdAt) / 1000 : chunk.minuteStartMs / 1000);
|
|
147
|
+
return `${this.minutePrefix(chunk.jobId, chunk.minuteStartMs)}chunk-${startTs}-${endTs}-${String(chunk.sequence).padStart(4, "0")}.ndjson`;
|
|
148
|
+
}
|
|
149
|
+
minutePrefix(jobId, minuteStartMs) {
|
|
150
|
+
return `${JOB_LOG_PREFIX}/job_id=${jobId}/minute=${Math.floor(minuteStartMs / 1000)}/`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export const jobLogStore = new JobLogStore();
|
|
154
|
+
function serializedEventSize(event) {
|
|
155
|
+
return Buffer.byteLength(`${JSON.stringify(event)}\n`, "utf8");
|
|
156
|
+
}
|
|
157
|
+
function floorToMinute(timestampMs) {
|
|
158
|
+
return Math.floor(timestampMs / 60_000) * 60_000;
|
|
159
|
+
}
|
|
160
|
+
function parseChunk(content) {
|
|
161
|
+
if (!content) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
return content
|
|
165
|
+
.split("\n")
|
|
166
|
+
.map((line) => line.trim())
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.map((line) => JSON.parse(line));
|
|
169
|
+
}
|
|
170
|
+
function dedupeAndSort(events) {
|
|
171
|
+
const deduped = new Map();
|
|
172
|
+
for (const event of events) {
|
|
173
|
+
deduped.set(event.id, event);
|
|
174
|
+
}
|
|
175
|
+
return [...deduped.values()].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
176
|
+
}
|
|
177
|
+
function withinRange(createdAt, startTime, endTime) {
|
|
178
|
+
const value = Date.parse(createdAt);
|
|
179
|
+
if (startTime && value < Date.parse(startTime)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (endTime && value > Date.parse(endTime)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
@@ -2,6 +2,7 @@ import { config } from "../config.js";
|
|
|
2
2
|
import { database } from "../db.js";
|
|
3
3
|
import { createId } from "../lib/ids.js";
|
|
4
4
|
import { addSeconds, nowIso } from "../lib/time.js";
|
|
5
|
+
import { jobLogStore } from "./job-logs.js";
|
|
5
6
|
const PENDING_JOB_STATUSES = ["queued", "running", "waiting_for_child", "waiting_for_human"];
|
|
6
7
|
export class JobsService {
|
|
7
8
|
assertQueueCapacity(customerId) {
|
|
@@ -72,8 +73,8 @@ export class JobsService {
|
|
|
72
73
|
listJobs(input) {
|
|
73
74
|
return database.listJobsForCustomer(input);
|
|
74
75
|
}
|
|
75
|
-
listLogs(input) {
|
|
76
|
-
return
|
|
76
|
+
async listLogs(input) {
|
|
77
|
+
return jobLogStore.listEvents(input);
|
|
77
78
|
}
|
|
78
79
|
cancelJob(jobId) {
|
|
79
80
|
database.updateJobStatus({
|
|
@@ -172,6 +172,7 @@ export class ProviderService {
|
|
|
172
172
|
prompt: input.prompt,
|
|
173
173
|
promptAttachments: input.promptAttachments,
|
|
174
174
|
size: input.size,
|
|
175
|
+
aspectRatio: input.aspectRatio,
|
|
175
176
|
apiKey: lease.secret
|
|
176
177
|
});
|
|
177
178
|
database.recordProviderKeyUsage({
|
|
@@ -268,6 +269,12 @@ export class ProviderService {
|
|
|
268
269
|
if (input.promptAttachments?.length) {
|
|
269
270
|
throw new Error("OpenAI image generation attachments are not implemented in this provider path. Use Gemini for reference attachments.");
|
|
270
271
|
}
|
|
272
|
+
const prompt = input.aspectRatio === "9:16"
|
|
273
|
+
? [
|
|
274
|
+
input.prompt,
|
|
275
|
+
"Return a native 9:16 vertical composition with no letterboxing, no inset landscape window, and no empty top or bottom padding."
|
|
276
|
+
].join("\n")
|
|
277
|
+
: input.prompt;
|
|
271
278
|
const response = await fetch("https://api.openai.com/v1/images/generations", {
|
|
272
279
|
method: "POST",
|
|
273
280
|
headers: {
|
|
@@ -276,9 +283,8 @@ export class ProviderService {
|
|
|
276
283
|
},
|
|
277
284
|
body: JSON.stringify({
|
|
278
285
|
model: input.model,
|
|
279
|
-
prompt
|
|
280
|
-
size: input.size ?? "1024x1536"
|
|
281
|
-
response_format: "b64_json"
|
|
286
|
+
prompt,
|
|
287
|
+
size: input.size ?? "1024x1536"
|
|
282
288
|
})
|
|
283
289
|
});
|
|
284
290
|
if (response.status === 401) {
|
|
@@ -288,7 +294,8 @@ export class ProviderService {
|
|
|
288
294
|
throw new ProviderRateLimitError("openai rate limited");
|
|
289
295
|
}
|
|
290
296
|
if (!response.ok) {
|
|
291
|
-
|
|
297
|
+
const details = await response.text();
|
|
298
|
+
throw new Error(`openai image generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
292
299
|
}
|
|
293
300
|
const data = await response.json();
|
|
294
301
|
const encoded = data.data?.[0]?.b64_json;
|
|
@@ -317,7 +324,7 @@ export class ProviderService {
|
|
|
317
324
|
body: JSON.stringify({
|
|
318
325
|
contents: [{ parts: await buildGeminiPromptParts(input.prompt, input.promptAttachments ?? []) }],
|
|
319
326
|
generationConfig: {
|
|
320
|
-
responseModalities: ["
|
|
327
|
+
responseModalities: ["Image"],
|
|
321
328
|
...(Object.keys(imageConfig).length
|
|
322
329
|
? {
|
|
323
330
|
imageConfig
|
|
@@ -333,7 +340,8 @@ export class ProviderService {
|
|
|
333
340
|
throw new ProviderRateLimitError("gemini rate limited");
|
|
334
341
|
}
|
|
335
342
|
if (!response.ok) {
|
|
336
|
-
|
|
343
|
+
const details = await response.text();
|
|
344
|
+
throw new Error(`gemini image generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
337
345
|
}
|
|
338
346
|
const data = await response.json();
|
|
339
347
|
const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
4
4
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
export class StorageService {
|
|
@@ -20,6 +20,9 @@ export class StorageService {
|
|
|
20
20
|
developerAttachmentKey(customerId, attachmentId, fileName) {
|
|
21
21
|
return joinStorageKey("developer", customerId, "attachments", attachmentId, fileName);
|
|
22
22
|
}
|
|
23
|
+
developerPreviewMediaKey(customerId, ...parts) {
|
|
24
|
+
return joinStorageKey("developer", customerId, "preview_media", ...parts);
|
|
25
|
+
}
|
|
23
26
|
templateJobPrefix(templateId, customerId, jobId) {
|
|
24
27
|
return joinStorageKey("templates", templateId, "users", customerId, "jobs", jobId);
|
|
25
28
|
}
|
|
@@ -77,6 +80,67 @@ export class StorageService {
|
|
|
77
80
|
async getReadUrl(key) {
|
|
78
81
|
return this.createReadUrl(key);
|
|
79
82
|
}
|
|
83
|
+
async createWriteUrl(key, input) {
|
|
84
|
+
if (!this.s3 || !config.AWS_S3_BUCKET) {
|
|
85
|
+
throw new Error("Presigned uploads require S3 storage.");
|
|
86
|
+
}
|
|
87
|
+
const publicRead = input.publicRead ?? config.s3PublicRead;
|
|
88
|
+
const expiresIn = input.expiresIn ?? 3600;
|
|
89
|
+
const command = new PutObjectCommand({
|
|
90
|
+
Bucket: config.AWS_S3_BUCKET,
|
|
91
|
+
Key: key,
|
|
92
|
+
ContentType: input.contentType,
|
|
93
|
+
ACL: publicRead ? "public-read" : undefined
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
url: await getSignedUrl(this.s3, command, { expiresIn }),
|
|
97
|
+
method: "PUT",
|
|
98
|
+
headers: {
|
|
99
|
+
"content-type": input.contentType,
|
|
100
|
+
...(publicRead ? { "x-amz-acl": "public-read" } : {})
|
|
101
|
+
},
|
|
102
|
+
expiresIn
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async readText(key) {
|
|
106
|
+
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
107
|
+
const response = await this.s3.send(new GetObjectCommand({
|
|
108
|
+
Bucket: config.AWS_S3_BUCKET,
|
|
109
|
+
Key: key
|
|
110
|
+
}));
|
|
111
|
+
if (!response.Body) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return response.Body.transformToString();
|
|
115
|
+
}
|
|
116
|
+
return readFileSync(path.join(this.localRoot, key), "utf8");
|
|
117
|
+
}
|
|
118
|
+
async listKeys(prefix) {
|
|
119
|
+
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
120
|
+
const keys = [];
|
|
121
|
+
let continuationToken;
|
|
122
|
+
do {
|
|
123
|
+
const response = await this.s3.send(new ListObjectsV2Command({
|
|
124
|
+
Bucket: config.AWS_S3_BUCKET,
|
|
125
|
+
Prefix: prefix,
|
|
126
|
+
ContinuationToken: continuationToken
|
|
127
|
+
}));
|
|
128
|
+
for (const object of response.Contents ?? []) {
|
|
129
|
+
if (object.Key) {
|
|
130
|
+
keys.push(object.Key);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
|
134
|
+
} while (continuationToken);
|
|
135
|
+
return keys;
|
|
136
|
+
}
|
|
137
|
+
const root = path.join(this.localRoot, prefix);
|
|
138
|
+
const keys = [];
|
|
139
|
+
walkLocalTree(root, (filePath) => {
|
|
140
|
+
keys.push(path.relative(this.localRoot, filePath).split(path.sep).join("/"));
|
|
141
|
+
});
|
|
142
|
+
return keys;
|
|
143
|
+
}
|
|
80
144
|
async deleteObject(key) {
|
|
81
145
|
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
82
146
|
await this.s3.send(new DeleteObjectCommand({
|
|
@@ -147,3 +211,22 @@ function inferContentType(filePath) {
|
|
|
147
211
|
return "application/octet-stream";
|
|
148
212
|
}
|
|
149
213
|
}
|
|
214
|
+
function walkLocalTree(root, onFile) {
|
|
215
|
+
try {
|
|
216
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
const childPath = path.join(root, entry.name);
|
|
219
|
+
if (entry.isDirectory()) {
|
|
220
|
+
walkLocalTree(childPath, onFile);
|
|
221
|
+
}
|
|
222
|
+
else if (entry.isFile()) {
|
|
223
|
+
onFile(childPath);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (error.code !== "ENOENT") {
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|