@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.
Files changed (40) hide show
  1. package/.env.example +6 -39
  2. package/GETTING_STARTED.developers.md +87 -0
  3. package/README.md +94 -238
  4. package/SKILL.developer.md +430 -104
  5. package/dist/src/account-pages.js +1 -1
  6. package/dist/src/app.js +93 -5
  7. package/dist/src/cli.js +456 -8
  8. package/dist/src/config.js +3 -2
  9. package/dist/src/context.js +30 -11
  10. package/dist/src/db.js +2 -57
  11. package/dist/src/dev-app.js +0 -1
  12. package/dist/src/index.js +4 -2
  13. package/dist/src/lib/template-paths.js +21 -0
  14. package/dist/src/runtime.js +3 -1
  15. package/dist/src/services/auth.js +4 -4
  16. package/dist/src/services/job-logs.js +186 -0
  17. package/dist/src/services/jobs.js +3 -2
  18. package/dist/src/services/providers.js +14 -6
  19. package/dist/src/services/storage.js +85 -2
  20. package/dist/src/services/template-sources.js +29 -3
  21. package/dist/templates/template_0000/src/lib/images.js +46 -86
  22. package/dist/templates/template_0000/src/template.js +277 -53
  23. package/package.json +5 -6
  24. package/templates/template_0000/README.md +8 -52
  25. package/templates/template_0000/SKILL.md +35 -3
  26. package/templates/template_0000/package.json +3 -6
  27. package/templates/template_0000/src/lib/images.js +46 -86
  28. package/templates/template_0000/src/lib/images.ts +55 -98
  29. package/templates/template_0000/src/template-dna.js +9 -0
  30. package/templates/template_0000/src/template.js +523 -199
  31. package/templates/template_0000/src/template.ts +356 -61
  32. package/templates/template_0000/template.config.json +7 -12
  33. package/AWS_REMOTION_HANDOFF.md +0 -311
  34. package/PLATFORM_SPEC.md +0 -1039
  35. package/SKILL.director.md +0 -599
  36. package/dist/infra/cdk/bin/vidfarm-prod.js +0 -59
  37. package/dist/infra/cdk/lib/vidfarm-prod-stack.js +0 -212
  38. package/templates/template_0000/package-lock.json +0 -5505
  39. package/templates/template_0000/scripts/create-site.mjs +0 -27
  40. package/templates/template_0000/scripts/render-cloud.mjs +0 -72
@@ -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
- database.addJobEvent({
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
- database.addJobEvent({
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
- database.addJobEvent({
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
- database.addJobEvent({
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
- database.addJobEvent({
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
- insertArtifact("json", stored.key, stored.url, {});
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 stored;
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
- insertArtifact("text", stored.key, stored.url, { contentType });
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 stored;
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
- insertArtifact(options.kind ?? "binary", stored.key, stored.url, {
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 stored;
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)
@@ -590,7 +590,6 @@ export function renderDevApp(input) {
590
590
  }
591
591
  return {
592
592
  "content-type": "application/json",
593
- "vidfarm-user-id": state.session.customer.id,
594
593
  "vidfarm-api-key": state.session.apiKey
595
594
  };
596
595
  }
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
- runtime.shutdown();
7
- process.exit(0);
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
+ }
@@ -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(userId, apiKey) {
73
- if (!userId || !apiKey) {
74
- throw new Error("Missing vidfarm-user-id or vidfarm-api-key header.");
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 || String(row.customer_id) !== userId) {
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 database.getJobEvents(input);
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: input.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
- throw new Error(`openai image generation returned ${response.status}`);
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: ["TEXT", "IMAGE"],
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
- throw new Error(`gemini image generation returned ${response.status}`);
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
+ }