@vfrey/vfrey 1.0.0-beta.0 → 1.0.0-beta.1
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/package.json +2 -2
- package/skills/vfrey-product/SKILL.md +3 -3
- package/src/api/client.ts +79 -0
- package/src/api/cloud-files.ts +197 -0
- package/src/api/cloud-runs.ts +75 -0
- package/src/api/cloud.ts +413 -0
- package/src/api/types.ts +68 -0
- package/src/cli.ts +4 -3
- package/src/cloud/bundle.ts +70 -0
- package/src/cloud/cloudrun-executor.ts +237 -0
- package/src/cloud/executor.ts +5 -5
- package/src/cloud/gcs-sign.ts +76 -0
- package/src/cloud/remote-job.ts +67 -0
- package/src/cloud/render-job.ts +4 -0
- package/src/help.ts +20 -1
- package/src/project/marker.ts +6 -1
- package/src/project/templates.ts +2 -2
- package/src/tick-report.ts +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { packFolder } from "./bundle.js";
|
|
4
|
+
import { containerName, ExecutorError, synthesized, type Executor, type ExecutorJob, type ExecutorResult } from "./executor.js";
|
|
5
|
+
import { signUrl, type FetchLike, type Signer, type TokenProvider } from "./gcs-sign.js";
|
|
6
|
+
import type { RenderJobResult } from "./job.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `cloud-run-jobs` executor (spec 019, section C2): one Cloud Run Job execution per render, over plain REST.
|
|
10
|
+
*
|
|
11
|
+
* Cloud Run Jobs https://docs.cloud.google.com/run/docs/reference/rest/v2/projects.locations.jobs/run
|
|
12
|
+
* Executions https://docs.cloud.google.com/run/docs/reference/rest/v2/projects.locations.jobs.executions
|
|
13
|
+
* Storage JSON https://docs.cloud.google.com/storage/docs/json_api
|
|
14
|
+
*
|
|
15
|
+
* The flow for a job id `J`:
|
|
16
|
+
* 1. the input folder is packed and uploaded to `gs://BUCKET/PREFIX/J/in.bundle.gz`;
|
|
17
|
+
* 2. one GET signed URL (the bundle) and one PUT signed URL per output file are made;
|
|
18
|
+
* 3. `jobs.run` starts an execution with overrides: args `render-job --remote`, the signed URLs in env, `timeout`;
|
|
19
|
+
* 4. the execution is polled until it completes (or the wall-clock limit passes and it is cancelled);
|
|
20
|
+
* 5. `result.json` and the other outputs are read from `gs://BUCKET/PREFIX/J/out/` into the output folder.
|
|
21
|
+
*
|
|
22
|
+
* The job's own service account holds NO bucket permission; it works only through the signed URLs. The job is deployed
|
|
23
|
+
* with Direct VPC egress (all traffic) into a VPC that has no Cloud NAT, so the scene has no route to the internet.
|
|
24
|
+
* `start`, `poll` and `collect` are the three steps for a caller (the console's cron) that cannot wait; `run` does all.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const OUTPUT_FILES = ["result.json", "video.mp4", "transcript.txt", "checks.json"] as const;
|
|
28
|
+
export const REMOTE_ARGS = ["render-job", "--remote"] as const;
|
|
29
|
+
|
|
30
|
+
export interface CloudRunOptions {
|
|
31
|
+
projectId: string;
|
|
32
|
+
/** For example `us-central1`. */
|
|
33
|
+
region: string;
|
|
34
|
+
/** The Cloud Run Job's name. It must run the render-worker image with `--command=vfrey`. */
|
|
35
|
+
jobName: string;
|
|
36
|
+
bucket: string;
|
|
37
|
+
/** An OAuth access token with the cloud-platform scope. Injected: a service account, or workload identity federation. */
|
|
38
|
+
token: TokenProvider;
|
|
39
|
+
/** Signs the URLs the container uses. */
|
|
40
|
+
signer: Signer;
|
|
41
|
+
fetch?: FetchLike;
|
|
42
|
+
/** Object prefix. Default `render-jobs`. */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
/** Wall-clock limit for one execution, in seconds. Default 600. Sent as the task timeout. */
|
|
45
|
+
timeoutSec?: number;
|
|
46
|
+
/** Extra environment for the render. Only these (and the signed URLs) reach the container. */
|
|
47
|
+
env?: Record<string, string>;
|
|
48
|
+
/** The container's name in the job, when it has more than one. */
|
|
49
|
+
containerName?: string;
|
|
50
|
+
pollIntervalMs?: number;
|
|
51
|
+
/** Extra seconds past `timeoutSec` before the executor gives up on Cloud Run and cancels. Default 120. */
|
|
52
|
+
graceSec?: number;
|
|
53
|
+
sleep?: (ms: number) => Promise<void>;
|
|
54
|
+
now?: () => Date;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CloudRunHandle {
|
|
58
|
+
jobId: string;
|
|
59
|
+
/** `projects/P/locations/R/jobs/J/executions/E`. */
|
|
60
|
+
executionName: string;
|
|
61
|
+
startedAt: number;
|
|
62
|
+
timeoutSec: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CloudRunPoll =
|
|
66
|
+
| { state: "running" }
|
|
67
|
+
| { state: "done"; succeeded: boolean; timedOut: boolean; renderSeconds: number; message: string };
|
|
68
|
+
|
|
69
|
+
interface Execution {
|
|
70
|
+
name?: string;
|
|
71
|
+
startTime?: string;
|
|
72
|
+
completionTime?: string;
|
|
73
|
+
succeededCount?: number;
|
|
74
|
+
failedCount?: number;
|
|
75
|
+
cancelledCount?: number;
|
|
76
|
+
conditions?: { type?: string; state?: string; reason?: string; message?: string }[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const GCS_ID = /^[a-z0-9][a-z0-9._-]{1,220}$/;
|
|
80
|
+
const round1 = (n: number) => Math.round(n * 10) / 10;
|
|
81
|
+
|
|
82
|
+
export class CloudRunExecutor implements Executor {
|
|
83
|
+
readonly kind = "cloud-run-jobs";
|
|
84
|
+
private readonly fetchImpl: FetchLike;
|
|
85
|
+
private readonly timeoutSec: number;
|
|
86
|
+
|
|
87
|
+
constructor(private readonly options: CloudRunOptions) {
|
|
88
|
+
for (const [field, value] of [["projectId", options.projectId], ["region", options.region], ["jobName", options.jobName], ["bucket", options.bucket]] as const) {
|
|
89
|
+
if (!value || !/^[A-Za-z0-9._-]+$/.test(value)) throw new ExecutorError(`${field} must be set to a plain name, not "${value}".`);
|
|
90
|
+
}
|
|
91
|
+
if (!GCS_ID.test(options.bucket)) throw new ExecutorError(`bucket "${options.bucket}" is not a valid bucket name.`);
|
|
92
|
+
this.timeoutSec = options.timeoutSec ?? 600;
|
|
93
|
+
if (!(this.timeoutSec > 0 && this.timeoutSec <= 3600)) throw new ExecutorError(`timeoutSec must be between 1 and 3600, not ${this.timeoutSec}.`);
|
|
94
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private objectPrefix(jobId: string): string {
|
|
98
|
+
containerName(jobId); // validates the id: letters, digits, dot, dash, underscore
|
|
99
|
+
return `${(this.options.prefix ?? "render-jobs").replace(/^\/+|\/+$/g, "")}/${jobId}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async api(url: string, init: RequestInit = {}): Promise<Response> {
|
|
103
|
+
const headers = new Headers(init.headers);
|
|
104
|
+
headers.set("authorization", `Bearer ${await this.options.token()}`);
|
|
105
|
+
return this.fetchImpl(url, { ...init, headers });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async json<T>(url: string, init: RequestInit, what: string): Promise<T> {
|
|
109
|
+
const response = await this.api(url, init);
|
|
110
|
+
if (!response.ok) throw new ExecutorError(`${what} failed with HTTP ${response.status}.`);
|
|
111
|
+
return (await response.json()) as T;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private objectUrl(object: string): string {
|
|
115
|
+
return `https://storage.googleapis.com/storage/v1/b/${this.options.bucket}/o/${encodeURIComponent(object)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async putObject(object: string, data: Buffer, type: string): Promise<void> {
|
|
119
|
+
const url = `https://storage.googleapis.com/upload/storage/v1/b/${this.options.bucket}/o?uploadType=media&name=${encodeURIComponent(object)}`;
|
|
120
|
+
const response = await this.api(url, { method: "POST", headers: { "content-type": type }, body: new Uint8Array(data) });
|
|
121
|
+
if (!response.ok) throw new ExecutorError(`Uploading ${object} failed with HTTP ${response.status}.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async getObject(object: string): Promise<Buffer | null> {
|
|
125
|
+
const response = await this.api(`${this.objectUrl(object)}?alt=media`);
|
|
126
|
+
if (response.status === 404) return null;
|
|
127
|
+
if (!response.ok) throw new ExecutorError(`Reading ${object} failed with HTTP ${response.status}.`);
|
|
128
|
+
return Buffer.from(await response.arrayBuffer());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Stages the inputs and starts the execution. Returns at once with a handle to poll. */
|
|
132
|
+
async start(job: ExecutorJob): Promise<CloudRunHandle> {
|
|
133
|
+
const { projectId, region, jobName, bucket } = this.options;
|
|
134
|
+
const prefix = this.objectPrefix(job.jobId);
|
|
135
|
+
const input = await stat(job.inputDir).catch(() => null);
|
|
136
|
+
if (!input?.isDirectory()) throw new ExecutorError(`inputDir "${job.inputDir}" is not a folder.`);
|
|
137
|
+
await this.putObject(`${prefix}/in.bundle.gz`, await packFolder(job.inputDir), "application/gzip");
|
|
138
|
+
|
|
139
|
+
const now = this.options.now?.() ?? new Date();
|
|
140
|
+
const expiresSec = this.timeoutSec + (this.options.graceSec ?? 120) + 900;
|
|
141
|
+
const inUrl = await signUrl(this.options.signer, { method: "GET", bucket, object: `${prefix}/in.bundle.gz`, expiresSec, now });
|
|
142
|
+
const outUrls: Record<string, string> = {};
|
|
143
|
+
for (const name of OUTPUT_FILES) outUrls[name] = await signUrl(this.options.signer, { method: "PUT", bucket, object: `${prefix}/out/${name}`, expiresSec, now });
|
|
144
|
+
|
|
145
|
+
const env = { ...(this.options.env ?? {}), VFREY_JOB_IN_URL: inUrl, VFREY_JOB_OUT_URLS: JSON.stringify(outUrls) };
|
|
146
|
+
const override: Record<string, unknown> = {
|
|
147
|
+
args: [...REMOTE_ARGS],
|
|
148
|
+
env: Object.entries(env).map(([name, value]) => ({ name, value })),
|
|
149
|
+
};
|
|
150
|
+
if (this.options.containerName) override.name = this.options.containerName;
|
|
151
|
+
const jobPath = `projects/${projectId}/locations/${region}/jobs/${jobName}`;
|
|
152
|
+
const operation = await this.json<{ name?: string; metadata?: { name?: string } }>(
|
|
153
|
+
`https://run.googleapis.com/v2/${jobPath}:run`,
|
|
154
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ overrides: { containerOverrides: [override], taskCount: 1, timeout: `${this.timeoutSec}s` } }) },
|
|
155
|
+
"Starting the Cloud Run job",
|
|
156
|
+
);
|
|
157
|
+
// The run operation's metadata is the Execution. If it is not there yet, ask the operation for it.
|
|
158
|
+
let executionName = operation.metadata?.name;
|
|
159
|
+
for (let attempt = 0; !executionName && operation.name && attempt < 10; attempt += 1) {
|
|
160
|
+
await this.sleep(500);
|
|
161
|
+
const again = await this.json<{ metadata?: { name?: string } }>(`https://run.googleapis.com/v2/${operation.name}`, {}, "Reading the Cloud Run operation");
|
|
162
|
+
executionName = again.metadata?.name;
|
|
163
|
+
}
|
|
164
|
+
if (!executionName || !executionName.startsWith(`${jobPath}/executions/`)) throw new ExecutorError("Cloud Run did not return an execution name.");
|
|
165
|
+
return { jobId: job.jobId, executionName, startedAt: (this.options.now?.() ?? new Date()).getTime(), timeoutSec: this.timeoutSec };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private sleep(ms: number): Promise<void> {
|
|
169
|
+
return this.options.sleep ? this.options.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One look at the execution. */
|
|
173
|
+
async poll(handle: CloudRunHandle): Promise<CloudRunPoll> {
|
|
174
|
+
const execution = await this.json<Execution>(`https://run.googleapis.com/v2/${handle.executionName}`, {}, "Reading the execution");
|
|
175
|
+
const elapsed = ((this.options.now?.() ?? new Date()).getTime() - handle.startedAt) / 1000;
|
|
176
|
+
const completed = execution.conditions?.find((c) => c.type === "Completed");
|
|
177
|
+
const finished = Boolean(execution.completionTime) || completed?.state === "CONDITION_SUCCEEDED" || completed?.state === "CONDITION_FAILED";
|
|
178
|
+
if (!finished) {
|
|
179
|
+
if (elapsed > handle.timeoutSec + (this.options.graceSec ?? 120)) {
|
|
180
|
+
// Cloud Run should have stopped it by now. Cancel, and report a timeout.
|
|
181
|
+
await this.api(`https://run.googleapis.com/v2/${handle.executionName}:cancel`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).catch(() => undefined);
|
|
182
|
+
return { state: "done", succeeded: false, timedOut: true, renderSeconds: round1(elapsed), message: "Cloud Run did not stop the execution in time and it was cancelled." };
|
|
183
|
+
}
|
|
184
|
+
return { state: "running" };
|
|
185
|
+
}
|
|
186
|
+
const start = execution.startTime ? Date.parse(execution.startTime) : NaN;
|
|
187
|
+
const end = execution.completionTime ? Date.parse(execution.completionTime) : NaN;
|
|
188
|
+
const renderSeconds = round1(Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, (end - start) / 1000) : elapsed);
|
|
189
|
+
const message = (completed?.message ?? completed?.reason ?? "").slice(0, 300);
|
|
190
|
+
const succeeded = (execution.succeededCount ?? 0) > 0 && (execution.failedCount ?? 0) === 0 && (execution.cancelledCount ?? 0) === 0;
|
|
191
|
+
// A task that passed its timeout is failed by Cloud Run; its condition says so.
|
|
192
|
+
const timedOut = !succeeded && /timed? ?out|deadline|exceeded the (maximum|task)/i.test(`${completed?.reason ?? ""} ${completed?.message ?? ""}`);
|
|
193
|
+
return { state: "done", succeeded, timedOut, renderSeconds, message };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Reads the outputs into `outputDir` and maps everything to the shared result shape. Then removes the job's objects. */
|
|
197
|
+
async collect(handle: CloudRunHandle, finished: Extract<CloudRunPoll, { state: "done" }>, outputDir: string): Promise<ExecutorResult> {
|
|
198
|
+
const prefix = this.objectPrefix(handle.jobId);
|
|
199
|
+
await mkdir(outputDir, { recursive: true });
|
|
200
|
+
let parsed: RenderJobResult | null = null;
|
|
201
|
+
if (!finished.timedOut) {
|
|
202
|
+
for (const name of OUTPUT_FILES) {
|
|
203
|
+
const data = await this.getObject(`${prefix}/out/${name}`);
|
|
204
|
+
if (data) await writeFile(path.join(outputDir, name), data);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(await readFile(path.join(outputDir, "result.json"), "utf8")) as RenderJobResult;
|
|
208
|
+
} catch {
|
|
209
|
+
parsed = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await this.cleanup(handle);
|
|
213
|
+
const tail = finished.message;
|
|
214
|
+
const result: RenderJobResult =
|
|
215
|
+
finished.timedOut
|
|
216
|
+
? synthesized("timeout", "render_timeout", `The render ran past its ${handle.timeoutSec} s limit and was stopped.`, finished.renderSeconds)
|
|
217
|
+
: parsed ?? synthesized("failed", "worker_crashed", `The Cloud Run job ended without a result.${tail ? ` ${tail}` : ""}`, finished.renderSeconds);
|
|
218
|
+
return { result, renderSeconds: finished.renderSeconds, exitCode: finished.succeeded ? 0 : 1, timedOut: finished.timedOut, stderrTail: tail };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Best effort: the bucket's lifecycle rule is the backstop. */
|
|
222
|
+
async cleanup(handle: CloudRunHandle): Promise<void> {
|
|
223
|
+
const prefix = this.objectPrefix(handle.jobId);
|
|
224
|
+
for (const object of [`${prefix}/in.bundle.gz`, ...OUTPUT_FILES.map((name) => `${prefix}/out/${name}`)]) {
|
|
225
|
+
await this.api(this.objectUrl(object), { method: "DELETE" }).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async run(job: ExecutorJob): Promise<ExecutorResult> {
|
|
230
|
+
const handle = await this.start(job);
|
|
231
|
+
for (;;) {
|
|
232
|
+
const state = await this.poll(handle);
|
|
233
|
+
if (state.state === "done") return this.collect(handle, state, job.outputDir);
|
|
234
|
+
await this.sleep(this.options.pollIntervalMs ?? 5000);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/cloud/executor.ts
CHANGED
|
@@ -15,8 +15,7 @@ import type { RenderJobResult } from "./job.js";
|
|
|
15
15
|
* 3. it reads the outputs (`video.mp4`, `transcript.txt`, `checks.json`, `result.json`), uploads them, and records
|
|
16
16
|
* `renderSeconds` for metering and credits.
|
|
17
17
|
* Either the console calls this module in-process, or a thin worker process polls the queue and calls it. Nothing in
|
|
18
|
-
* here knows about the queue or the database. `local-docker` runs `docker run` on this machine;
|
|
19
|
-
* executor (slice C2) implements the same interface with Google Cloud Run Jobs.
|
|
18
|
+
* here knows about the queue or the database. `local-docker` runs `docker run` on this machine; `cloud-run-jobs` (./cloudrun-executor.ts, slice C2) implements the same interface with Google Cloud Run Jobs.
|
|
20
19
|
*/
|
|
21
20
|
|
|
22
21
|
export interface ExecutorJob {
|
|
@@ -161,7 +160,7 @@ export function buildDockerArgs(job: ExecutorJob, options: LocalDockerOptions =
|
|
|
161
160
|
];
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
function synthesized(status: "failed" | "timeout", code: string, message: string, seconds: number): RenderJobResult {
|
|
163
|
+
export function synthesized(status: "failed" | "timeout", code: string, message: string, seconds: number): RenderJobResult {
|
|
165
164
|
return {
|
|
166
165
|
version: 1,
|
|
167
166
|
status,
|
|
@@ -274,8 +273,9 @@ export class LocalDockerExecutor implements Executor {
|
|
|
274
273
|
}
|
|
275
274
|
}
|
|
276
275
|
|
|
277
|
-
/** The executor for `kind`.
|
|
276
|
+
/** The executor for `kind`. `cloud-run-jobs` needs a project, a bucket and credentials: build it with `CloudRunExecutor` (./cloudrun-executor.ts). */
|
|
278
277
|
export function createExecutor(kind: string, options: LocalDockerOptions = {}): Executor {
|
|
279
278
|
if (kind === "local-docker") return new LocalDockerExecutor(options);
|
|
280
|
-
throw new ExecutorError(
|
|
279
|
+
if (kind === "cloud-run-jobs") throw new ExecutorError('The "cloud-run-jobs" executor needs a project, region, job, bucket and credentials. Build it with new CloudRunExecutor(options).');
|
|
280
|
+
throw new ExecutorError(`There is no "${kind}" executor. Available: local-docker, cloud-run-jobs.`);
|
|
281
281
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash, createSign } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloud Storage V4 signed URLs, with no SDK (spec 019, section C2).
|
|
5
|
+
* https://docs.cloud.google.com/storage/docs/access-control/signed-urls
|
|
6
|
+
* https://docs.cloud.google.com/storage/docs/authentication/signatures
|
|
7
|
+
*
|
|
8
|
+
* Why signed URLs: the Cloud Run job container needs NO permission on the bucket. It is handed one GET URL for its input
|
|
9
|
+
* bundle and one PUT URL per output file, each good for one object and a short time. A scene that took the container's
|
|
10
|
+
* credentials could therefore not read another job's files. The signer is either a service account key (`keySigner`) or,
|
|
11
|
+
* with no key on the machine, the IAM Credentials `signBlob` call (`iamSigner`), which works with workload identity federation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Signs the string to sign and returns the signature as lower-case hex. */
|
|
15
|
+
export type Signer = { email: string; sign(stringToSign: string): Promise<string> };
|
|
16
|
+
export type TokenProvider = () => Promise<string>;
|
|
17
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
const HOST = "storage.googleapis.com";
|
|
20
|
+
const MAX_EXPIRES_SEC = 7 * 24 * 3600;
|
|
21
|
+
|
|
22
|
+
/** Signs locally with the private key of a service account (PEM, as in the key file's `private_key`). */
|
|
23
|
+
export function keySigner(email: string, privateKeyPem: string): Signer {
|
|
24
|
+
return {
|
|
25
|
+
email,
|
|
26
|
+
async sign(stringToSign) {
|
|
27
|
+
return createSign("RSA-SHA256").update(stringToSign).sign(privateKeyPem, "hex");
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Signs through `iamcredentials.googleapis.com` as `email`. The caller needs roles/iam.serviceAccountTokenCreator on it. */
|
|
33
|
+
export function iamSigner(email: string, token: TokenProvider, fetchImpl: FetchLike = fetch): Signer {
|
|
34
|
+
return {
|
|
35
|
+
email,
|
|
36
|
+
async sign(stringToSign) {
|
|
37
|
+
const response = await fetchImpl(`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(email)}:signBlob`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { authorization: `Bearer ${await token()}`, "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ payload: Buffer.from(stringToSign, "utf8").toString("base64") }),
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) throw new Error(`signBlob failed with HTTP ${response.status}.`);
|
|
43
|
+
const body = (await response.json()) as { signedBlob?: string };
|
|
44
|
+
if (!body.signedBlob) throw new Error("signBlob returned no signature.");
|
|
45
|
+
return Buffer.from(body.signedBlob, "base64").toString("hex");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const encode = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
51
|
+
const encodePath = (value: string) => value.split("/").map(encode).join("/");
|
|
52
|
+
const stamp = (now: Date) => now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
53
|
+
|
|
54
|
+
/** The V4 string to sign for a GET or PUT on one object. Exported for tests. */
|
|
55
|
+
export function v4Request(opts: { method: "GET" | "PUT"; bucket: string; object: string; email: string; expiresSec: number; now: Date }) {
|
|
56
|
+
if (!(opts.expiresSec > 0 && opts.expiresSec <= MAX_EXPIRES_SEC)) throw new Error("A signed URL lives 1 second to 7 days.");
|
|
57
|
+
const datetime = stamp(opts.now);
|
|
58
|
+
const scope = `${datetime.slice(0, 8)}/auto/storage/goog4_request`;
|
|
59
|
+
const query: [string, string][] = [
|
|
60
|
+
["X-Goog-Algorithm", "GOOG4-RSA-SHA256"],
|
|
61
|
+
["X-Goog-Credential", `${opts.email}/${scope}`],
|
|
62
|
+
["X-Goog-Date", datetime],
|
|
63
|
+
["X-Goog-Expires", String(Math.floor(opts.expiresSec))],
|
|
64
|
+
["X-Goog-SignedHeaders", "host"],
|
|
65
|
+
];
|
|
66
|
+
const canonicalQuery = query.map(([k, v]) => `${encode(k)}=${encode(v)}`).sort().join("&");
|
|
67
|
+
const resource = `/${opts.bucket}/${encodePath(opts.object)}`;
|
|
68
|
+
const canonical = [opts.method, resource, canonicalQuery, `host:${HOST}`, "", "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
69
|
+
const stringToSign = ["GOOG4-RSA-SHA256", datetime, scope, createHash("sha256").update(canonical).digest("hex")].join("\n");
|
|
70
|
+
return { canonical, stringToSign, canonicalQuery, resource };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function signUrl(signer: Signer, opts: { method: "GET" | "PUT"; bucket: string; object: string; expiresSec: number; now?: Date }): Promise<string> {
|
|
74
|
+
const { stringToSign, canonicalQuery, resource } = v4Request({ ...opts, email: signer.email, now: opts.now ?? new Date() });
|
|
75
|
+
return `https://${HOST}${resource}?${canonicalQuery}&X-Goog-Signature=${await signer.sign(stringToSign)}`;
|
|
76
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { unpackBundle } from "./bundle.js";
|
|
5
|
+
import type { FetchLike } from "./gcs-sign.js";
|
|
6
|
+
import type { RenderJobResult } from "./job.js";
|
|
7
|
+
import { runRenderJob, type RenderJobIo } from "./render-job.js";
|
|
8
|
+
|
|
9
|
+
type Env = Record<string, string | undefined>;
|
|
10
|
+
|
|
11
|
+
/** The variables the cloud executor sets. They are removed from the environment the scene's render sees. */
|
|
12
|
+
export const REMOTE_ENV = ["VFREY_JOB_IN_URL", "VFREY_JOB_OUT_URLS"] as const;
|
|
13
|
+
const OUTPUT_NAMES = ["video.mp4", "transcript.txt", "checks.json", "result.json"] as const;
|
|
14
|
+
|
|
15
|
+
/** A signed URL is only ever used if it points at Cloud Storage. */
|
|
16
|
+
function storageUrl(value: unknown, field: string): string {
|
|
17
|
+
if (typeof value !== "string") throw new Error(`${field} is not set.`);
|
|
18
|
+
const url = new URL(value);
|
|
19
|
+
if (url.protocol !== "https:" || url.hostname !== "storage.googleapis.com") throw new Error(`${field} must be an https://storage.googleapis.com URL.`);
|
|
20
|
+
return url.toString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `vfrey render-job --remote`: the Cloud Run Job's command (spec 019, section C2). It downloads the input bundle through
|
|
25
|
+
* a signed URL, renders with the same code as the local container, and uploads each output through its signed URL. It holds
|
|
26
|
+
* no credentials: the URLs are the only access, and they are removed from the environment before the render starts.
|
|
27
|
+
*
|
|
28
|
+
* Exit 0 when the outputs (above all `result.json`) were uploaded, even if the scene failed: the result file says so, and
|
|
29
|
+
* a non-zero exit would only make Cloud Run retry. Exit 1 when a download or an upload failed, which the executor reads
|
|
30
|
+
* as an infrastructure failure.
|
|
31
|
+
*/
|
|
32
|
+
export async function runRemoteJob(io: { env: Env; stdout: (t: string) => void; stderr: (t: string) => void; fetch?: FetchLike; workRoot?: string; render?: (io: RenderJobIo) => Promise<RenderJobResult> }): Promise<number> {
|
|
33
|
+
const fetchImpl = io.fetch ?? fetch;
|
|
34
|
+
const work = await mkdtemp(path.join(io.workRoot ?? os.tmpdir(), "vfrey-remote-"));
|
|
35
|
+
try {
|
|
36
|
+
const inUrl = storageUrl(io.env.VFREY_JOB_IN_URL, "VFREY_JOB_IN_URL");
|
|
37
|
+
const outUrls = JSON.parse(io.env.VFREY_JOB_OUT_URLS ?? "null") as Record<string, string> | null;
|
|
38
|
+
if (!outUrls) throw new Error("VFREY_JOB_OUT_URLS is not set.");
|
|
39
|
+
const targets = Object.fromEntries(OUTPUT_NAMES.map((name) => [name, storageUrl(outUrls[name], `VFREY_JOB_OUT_URLS.${name}`)]));
|
|
40
|
+
|
|
41
|
+
const response = await fetchImpl(inUrl);
|
|
42
|
+
if (!response.ok) throw new Error(`Downloading the input bundle failed with HTTP ${response.status}.`);
|
|
43
|
+
const inputDir = path.join(work, "in");
|
|
44
|
+
const outputDir = path.join(work, "out");
|
|
45
|
+
await mkdir(outputDir, { recursive: true });
|
|
46
|
+
await unpackBundle(Buffer.from(await response.arrayBuffer()), inputDir);
|
|
47
|
+
|
|
48
|
+
const env: Env = { ...io.env };
|
|
49
|
+
for (const name of REMOTE_ENV) delete env[name];
|
|
50
|
+
const result = await (io.render ?? runRenderJob)({ inputDir, outputDir, env, stderr: io.stderr });
|
|
51
|
+
io.stdout(`${JSON.stringify({ status: result.status, code: result.code, message: result.message })}\n`);
|
|
52
|
+
|
|
53
|
+
// result.json goes last: when it exists, every other output already does.
|
|
54
|
+
for (const name of [...OUTPUT_NAMES].sort((a) => (a === "result.json" ? 1 : -1))) {
|
|
55
|
+
const file = path.join(outputDir, name);
|
|
56
|
+
if (!(await stat(file).catch(() => null))) continue;
|
|
57
|
+
const put = await fetchImpl(targets[name]!, { method: "PUT", body: new Uint8Array(await readFile(file)) });
|
|
58
|
+
if (!put.ok) throw new Error(`Uploading ${name} failed with HTTP ${put.status}.`);
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
io.stderr(`✗ render-job --remote: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
63
|
+
return 1;
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(work, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/cloud/render-job.ts
CHANGED
|
@@ -170,6 +170,10 @@ export async function runRenderJob(io: RenderJobIo): Promise<RenderJobResult> {
|
|
|
170
170
|
|
|
171
171
|
/** `vfrey render-job --input DIR --output DIR`: the container's command. Exit 0 only for a succeeded job; the result file has the rest. */
|
|
172
172
|
export async function runRenderJobCommand(argv: string[], io: { env: NodeJS.ProcessEnv; stdout: (t: string) => void; stderr: (t: string) => void }): Promise<number> {
|
|
173
|
+
if (argv.includes("--remote")) {
|
|
174
|
+
const { runRemoteJob } = await import("./remote-job.js");
|
|
175
|
+
return runRemoteJob(io);
|
|
176
|
+
}
|
|
173
177
|
const flag = (name: string) => {
|
|
174
178
|
const at = argv.indexOf(name);
|
|
175
179
|
const value = at === -1 ? undefined : argv[at + 1];
|
package/src/help.ts
CHANGED
|
@@ -97,6 +97,25 @@ export const HELP: Record<string, CommandHelp> = {
|
|
|
97
97
|
["--api URL", "the vfrey server (default: VFREY_API_URL)"],
|
|
98
98
|
],
|
|
99
99
|
},
|
|
100
|
+
cloud: {
|
|
101
|
+
summary: "Sync this project with a hosted one in your org: scenes, scripts, run details and (optionally) videos.",
|
|
102
|
+
usage: [
|
|
103
|
+
"npx vfrey cloud link",
|
|
104
|
+
"npx vfrey cloud push [--videos] [--dry-run] [--json]",
|
|
105
|
+
"npx vfrey cloud pull [--json]",
|
|
106
|
+
"npx vfrey cloud status [--json]",
|
|
107
|
+
],
|
|
108
|
+
flags: [
|
|
109
|
+
["link", "connect this project (by its vfrey.json id) to a cloud project in the org you are signed in to; records cloudProjectId in vfrey.json"],
|
|
110
|
+
["push", "upload the scenes and scripts that changed, and the run details (stages, checks, what was published). Only changed files are sent"],
|
|
111
|
+
["--videos", "with push: also upload finished videos and transcripts (large; counted against your plan's cloud storage)"],
|
|
112
|
+
["--dry-run", "with push: show what would be sent and send nothing"],
|
|
113
|
+
["pull", "download scenes and scripts changed in the cloud. If a file changed in both places the newest wins and the other is kept as <file>.conflict-<time>"],
|
|
114
|
+
["status", "what is in sync, what to push, what to pull, and conflicts"],
|
|
115
|
+
["--json", "print one JSON document"],
|
|
116
|
+
["--api URL", "the vfrey server (default: VFREY_API_URL)"],
|
|
117
|
+
],
|
|
118
|
+
},
|
|
100
119
|
tick: {
|
|
101
120
|
summary: "Run the pipeline once: source, generate, render, publish. Without --now it follows each series' cadence.",
|
|
102
121
|
usage: [
|
|
@@ -205,7 +224,7 @@ export const HELP: Record<string, CommandHelp> = {
|
|
|
205
224
|
},
|
|
206
225
|
};
|
|
207
226
|
|
|
208
|
-
const ORDER = ["doctor", "tick", "worker", "service", "engage", "analytics", "render", "scene", "engine", "dashboard", "open", "push", "update", "desk", "skill", "init", "videos", "projects", "login", "logout", "whoami", "org", "topics", "creators", "mail"];
|
|
227
|
+
const ORDER = ["doctor", "tick", "worker", "service", "engage", "analytics", "render", "scene", "engine", "dashboard", "open", "push", "update", "desk", "skill", "init", "videos", "projects", "login", "logout", "whoami", "org", "topics", "creators", "mail", "cloud"];
|
|
209
228
|
|
|
210
229
|
export function generalHelp(): string {
|
|
211
230
|
const lines = ["vfrey: make short videos from your agent or the command line.", "", "Usage: npx vfrey <command> [flags]", "", "Commands:"];
|
package/src/project/marker.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface ProjectMarker {
|
|
|
13
13
|
id: string;
|
|
14
14
|
/** This project's preferred render engine: beats the machine default, loses to `--engine` and a series engine. */
|
|
15
15
|
engine?: string;
|
|
16
|
+
/** The hosted project this one syncs with (spec 019 B), set by `vfrey cloud link`. */
|
|
17
|
+
cloudProjectId?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** A vfrey project: the folder that holds `vfrey.json`. */
|
|
@@ -21,6 +23,8 @@ export interface Project {
|
|
|
21
23
|
id: string;
|
|
22
24
|
/** The engine named in vfrey.json, if any. */
|
|
23
25
|
engine?: string;
|
|
26
|
+
/** The hosted project id from `vfrey cloud link`, if linked. */
|
|
27
|
+
cloudProjectId?: string;
|
|
24
28
|
root: string;
|
|
25
29
|
/** `<root>/.vfrey`, where the database, logs and per-job copies live. */
|
|
26
30
|
dataDir: string;
|
|
@@ -46,6 +50,7 @@ export function validateMarker(value: unknown): ProjectMarker | string {
|
|
|
46
50
|
if (marker.engine !== undefined && !(typeof marker.engine === "string" && (ENGINE_IDS as readonly string[]).includes(marker.engine))) {
|
|
47
51
|
return `"engine" must be one of ${ENGINE_IDS.join(", ")}`;
|
|
48
52
|
}
|
|
53
|
+
if (marker.cloudProjectId !== undefined && !(typeof marker.cloudProjectId === "string" && marker.cloudProjectId.trim())) return `"cloudProjectId" must be a non-empty string`;
|
|
49
54
|
return { ...marker, version: 1, name: marker.name.trim(), id: marker.id.trim() } as ProjectMarker;
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -83,5 +88,5 @@ export function hasMarker(root: string): boolean {
|
|
|
83
88
|
export function projectAt(rootPath: string): Project {
|
|
84
89
|
const root = canonicalPath(rootPath);
|
|
85
90
|
const marker = readMarker(root);
|
|
86
|
-
return { name: marker.name, id: marker.id, ...(marker.engine ? { engine: marker.engine } : {}), root, dataDir: path.join(root, ".vfrey") };
|
|
91
|
+
return { name: marker.name, id: marker.id, ...(marker.engine ? { engine: marker.engine } : {}), ...(marker.cloudProjectId ? { cloudProjectId: marker.cloudProjectId } : {}), root, dataDir: path.join(root, ".vfrey") };
|
|
87
92
|
}
|
package/src/project/templates.ts
CHANGED
|
@@ -10,9 +10,9 @@ This folder makes short videos. The person talks to you; you run \`npx vfrey\`.
|
|
|
10
10
|
Use the vfrey skill (\`.claude/skills/vfrey\` or \`.agents/skills/vfrey\`) if you have it. If not, this is the flow:
|
|
11
11
|
|
|
12
12
|
1. Write the script yourself: \`scripts/<idea>.json\` (\`npx vfrey tick --script-template\` prints an example). Plain and accurate, 6 to 10 short lines, nothing invented.
|
|
13
|
-
2.
|
|
13
|
+
2. Always design a scene (the default template is only for an explicit quick text-only video; say so). Design it for the idea in \`scenes/<idea>/\` (\`npx vfrey scene init scenes/<idea>\`). No fixed templates: two ideas should not look alike. Preview with \`npx vfrey render --scene scenes/<idea> --engine ${engine ?? "<engine>"} --script scripts/<idea>.json --frame 2.5 out.png\`.
|
|
14
14
|
3. \`npx vfrey tick --now --about "<subject>" --script scripts/<idea>.json --scene scenes/<idea> --engine ${engine ?? "<engine>"}\`
|
|
15
|
-
4. Tell the person the path of the video in \`videos/\` and the \`See it:\` link
|
|
15
|
+
4. Tell the person the path of the video in \`videos/\` and the \`See it:\` link as printed: it is live (the tick starts the dashboard), so do not hedge; only if \`curl\` to it fails, run \`npx vfrey open\`.
|
|
16
16
|
|
|
17
17
|
## Commands
|
|
18
18
|
|
package/src/tick-report.ts
CHANGED
|
@@ -178,7 +178,7 @@ export function formatTickReport(report: TickReport, view: TickReportView = {}):
|
|
|
178
178
|
const facts = job.durationSec !== null ? ` (${job.durationSec} s${job.width && job.height ? `, ${job.width}×${job.height}` : ""})` : "";
|
|
179
179
|
lines.push(`✓ Video ready: ${job.mp4}${facts}`);
|
|
180
180
|
if (job.engine) lines.push(` Engine: ${job.engine.label}${job.engine.source === "project" ? " (from vfrey.json)" : ""}`);
|
|
181
|
-
if (job.scene) lines.push(` Scene: ${job.scene.source === "agent" ? "agent-written" : "default template"}`);
|
|
181
|
+
if (job.scene) lines.push(` Scene: ${job.scene.source === "agent" ? "agent-written" : "default template (plain text). Design one next time: npx vfrey scene init scenes/<idea>"}`);
|
|
182
182
|
if (job.transcript) lines.push(` Transcript: ${job.transcript}`);
|
|
183
183
|
const ran = job.checks.filter((check) => check.status !== "skipped").length;
|
|
184
184
|
if (ran > 0) lines.push(` Checks: ${ran} passed`);
|