cozyclay 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +91 -25
- package/THIRD_PARTY_NOTICES.md +68 -1
- package/bin/cozyclay.mjs +71 -8
- package/dist/CNAME +1 -0
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +57 -0
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/favicon.ico +0 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +450 -5
- package/dist/manifest.webmanifest +42 -0
- package/dist/media/cozyclay-demo-poster.jpg +0 -0
- package/dist/media/cozyclay-logo.png +0 -0
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +15 -0
- package/dist/sw.js +127 -0
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -78
- package/src/App.jsx +4235 -1047
- package/src/ardy/client.js +5 -4
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/mp4-duration.js +102 -0
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +24 -0
- package/src/ardy/timeline.jsx +650 -70
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/ardy/waypoints.js +14 -9
- package/src/asset-pane.jsx +36 -0
- package/src/camera-block.js +60 -0
- package/src/camera-follow.js +49 -10
- package/src/camera-rail-schedule.js +176 -0
- package/src/controls.jsx +16 -2
- package/src/cuts.js +184 -0
- package/src/dualview.jsx +14 -8
- package/src/generation/client.js +56 -0
- package/src/generation/session.js +47 -0
- package/src/generation/shot-spec.js +40 -0
- package/src/generation/use-generation.js +55 -0
- package/src/hierarchy-model.js +104 -30
- package/src/hierarchy-panel.jsx +306 -26
- package/src/live-control.js +122 -0
- package/src/locale-toggle.jsx +18 -0
- package/src/locale.js +34 -0
- package/src/main.jsx +5 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-catalog.jsx +32 -3
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +208 -89
- package/src/pose-extract/browser.js +42 -0
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/fit.js +194 -0
- package/src/pose-extract/index.js +56 -0
- package/src/pose-extract/keyframes.js +74 -0
- package/src/pose-extract/landmarks.js +162 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/poses.js +114 -0
- package/src/posestudio.jsx +434 -22
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/pwa.js +42 -0
- package/src/result-modal.jsx +124 -0
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +404 -0
- package/src/shot-authoring.js +246 -77
- package/src/shot.js +8 -4
- package/src/styles.css +2151 -183
- package/src/ui.jsx +54 -5
- package/tools/ardy/BRIDGE.md +73 -6
- package/tools/ardy/README.md +64 -5
- package/tools/ardy/bridge.mjs +146 -390
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +561 -0
- package/tools/ardy/cclay_motion_edit.py +74 -9
- package/tools/ardy/cclay_sequence_generate.py +117 -7
- package/tools/ardy/cclay_worker.py +304 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/merge-text-encoder.py +160 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-local.mjs +233 -0
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/index.mjs +23 -0
- package/tools/ardy/runners/local.mjs +452 -0
- package/tools/ardy/runners/proc.mjs +125 -0
- package/tools/ardy/runners/remote.mjs +252 -0
- package/tools/ardy/setup-local.mjs +189 -0
- package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
- package/tools/ardy/setup-text-encoder.py +296 -0
- package/tools/generation/README.md +29 -0
- package/tools/generation/bridge.mjs +149 -0
- package/tools/generation/job-store.mjs +51 -0
- package/tools/generation/providers/kling.mjs +76 -0
- package/tools/generation/providers/runway.mjs +101 -0
- package/tools/generation/providers/seedance.mjs +87 -0
- package/tools/generation/providers/shared.mjs +49 -0
- package/tools/generation/providers/veo.mjs +84 -0
- package/tools/metrics/archive-traffic.mjs +138 -0
- package/dist/assets/index-BW_S1YRy.js +0 -4395
- package/dist/assets/index-nRmmJgap.css +0 -1
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/qa-browser.mjs +0 -84
- package/tools/qa-crop3.mjs +0 -22
- package/tools/qa-playview.mjs +0 -49
- package/tools/qa-screenshot.mjs +0 -24
- package/tools/qa-visual.mjs +0 -51
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const API = "https://api.dev.runwayml.com/v1";
|
|
2
|
+
const VERSION = "2024-11-06";
|
|
3
|
+
|
|
4
|
+
export const RUNWAY_MODELS = Object.freeze([
|
|
5
|
+
{ id: "gen4_turbo", label: "Runway Gen-4 Turbo", endpoint: "image_to_video", durations: [2, 3, 4, 5, 6, 7, 8, 9, 10], costPerSecondUsd: 0.05, capabilities: { prompt: true, startFrame: true, endFrame: false, referenceVideo: false, audio: false } },
|
|
6
|
+
{ id: "gen4.5", label: "Runway Gen-4.5", endpoint: "image_to_video", durations: [2, 3, 4, 5, 6, 7, 8, 9, 10], costPerSecondUsd: 0.12, capabilities: { prompt: true, startFrame: true, endFrame: false, referenceVideo: false, audio: false } },
|
|
7
|
+
{ id: "seedance2", label: "Seedance 2.0 via Runway", endpoint: "image_to_video", durations: [2, 3, 4, 5, 6, 7, 8, 9, 10], costPerSecondUsd: 0.36, capabilities: { prompt: true, startFrame: true, endFrame: false, referenceVideo: false, audio: true } },
|
|
8
|
+
{ id: "veo3.1", label: "Google Veo 3.1 via Runway", endpoint: "image_to_video", durations: [4, 6, 8], costPerSecondUsd: 0.4, capabilities: { prompt: true, startFrame: true, endFrame: false, referenceVideo: false, audio: true } },
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const RETRYABLE = new Set([429, 502, 503, 504]);
|
|
12
|
+
|
|
13
|
+
async function runwayFetch(url, options, { fetchImpl, signal, timeoutMs = 15000, retries = 3, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random = Math.random }) {
|
|
14
|
+
let response;
|
|
15
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
16
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
17
|
+
const attemptSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
18
|
+
try {
|
|
19
|
+
response = await fetchImpl(url, { ...options, signal: attemptSignal });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (signal?.aborted || attempt === retries) throw error;
|
|
22
|
+
await sleep(250 * (2 ** attempt) * (1 + random() * 0.5));
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!RETRYABLE.has(response.status) || attempt === retries) return response;
|
|
26
|
+
await response.body?.cancel().catch(() => {});
|
|
27
|
+
await sleep(250 * (2 ** attempt) * (1 + random() * 0.5));
|
|
28
|
+
}
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function headers(apiKey) {
|
|
33
|
+
return { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "X-Runway-Version": VERSION };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function modelOf(id) {
|
|
37
|
+
if (typeof id !== "string" || !id) throw new Error("model is required");
|
|
38
|
+
const model = RUNWAY_MODELS.find((candidate) => candidate.id === id);
|
|
39
|
+
if (!model) throw new Error(`unsupported Runway model: ${id}`);
|
|
40
|
+
return model;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function estimateRunwayCost(spec) {
|
|
44
|
+
const model = modelOf(spec.model);
|
|
45
|
+
return Number((model.costPerSecondUsd * spec.durationSeconds).toFixed(2));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateRunwaySpec(spec) {
|
|
49
|
+
const model = modelOf(spec.model);
|
|
50
|
+
const errors = [];
|
|
51
|
+
const warnings = [];
|
|
52
|
+
if (!spec.prompt?.trim()) errors.push("prompt is required");
|
|
53
|
+
if (spec.prompt?.length > 1000) errors.push("Runway prompt must be at most 1000 UTF-16 code units");
|
|
54
|
+
if (!model.durations.includes(spec.durationSeconds)) errors.push(`${model.label} duration must be ${model.durations.join(" or ")} seconds`);
|
|
55
|
+
if (!spec.conditioning?.startFrame) errors.push(`${model.label} requires a start frame`);
|
|
56
|
+
if (spec.conditioning?.startFrame?.startsWith("data:") && spec.conditioning.startFrame.length > 5 * 1024 * 1024) errors.push("Runway data URI images must be at most 5 MB encoded");
|
|
57
|
+
if (spec.conditioning?.endFrame && !model.capabilities.endFrame) warnings.push("end frame is not supported by this adapter and will not be sent");
|
|
58
|
+
if (spec.conditioning?.referenceVideo && !model.capabilities.referenceVideo) warnings.push("reference video is not supported by this adapter and will not be sent");
|
|
59
|
+
return { ok: errors.length === 0, errors, warnings, model };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function submitRunway(spec, { apiKey = process.env.RUNWAYML_API_SECRET, fetchImpl = fetch, signal, ...retryOptions } = {}) {
|
|
63
|
+
if (!apiKey) throw new Error("RUNWAYML_API_SECRET is not configured");
|
|
64
|
+
const verdict = validateRunwaySpec(spec);
|
|
65
|
+
if (!verdict.ok) throw new Error(verdict.errors[0]);
|
|
66
|
+
const body = {
|
|
67
|
+
model: spec.model,
|
|
68
|
+
promptText: spec.prompt,
|
|
69
|
+
promptImage: spec.conditioning.startFrame,
|
|
70
|
+
ratio: spec.aspectRatio === "9:16" ? "720:1280" : "1280:720",
|
|
71
|
+
duration: spec.durationSeconds,
|
|
72
|
+
};
|
|
73
|
+
if (Number.isInteger(spec.seed)) body.seed = spec.seed;
|
|
74
|
+
const response = await runwayFetch(`${API}/${verdict.model.endpoint}`, { method: "POST", headers: headers(apiKey), body: JSON.stringify(body) }, { fetchImpl, signal, ...retryOptions });
|
|
75
|
+
const payload = await response.json().catch(() => ({}));
|
|
76
|
+
if (!response.ok) throw new Error(`Runway submit failed (HTTP ${response.status})`);
|
|
77
|
+
if (!payload.id) throw new Error("Runway submit response has no task id");
|
|
78
|
+
return { providerJobId: payload.id, warnings: verdict.warnings };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function pollRunway(providerJobId, { apiKey = process.env.RUNWAYML_API_SECRET, fetchImpl = fetch, signal, ...retryOptions } = {}) {
|
|
82
|
+
if (!apiKey) throw new Error("RUNWAYML_API_SECRET is not configured");
|
|
83
|
+
const response = await runwayFetch(`${API}/tasks/${encodeURIComponent(providerJobId)}`, { headers: headers(apiKey) }, { fetchImpl, signal, ...retryOptions });
|
|
84
|
+
const payload = await response.json().catch(() => ({}));
|
|
85
|
+
if (!response.ok) throw new Error(`Runway poll failed (HTTP ${response.status})`);
|
|
86
|
+
const state = String(payload.status || "").toUpperCase();
|
|
87
|
+
const status = state === "SUCCEEDED" ? "succeeded" : state === "FAILED" || state === "CANCELED" ? "failed" : "processing";
|
|
88
|
+
return { status, progress: payload.progress ?? null, outputUrl: status === "succeeded" ? payload.output?.[0] ?? null : null, failure: status === "failed" ? payload.failure || payload.failureCode || "generation failed" : null, raw: payload };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const runwayProvider = Object.freeze({
|
|
92
|
+
id: "runway",
|
|
93
|
+
label: "Runway",
|
|
94
|
+
auth: { type: "bearer-env", env: "RUNWAYML_API_SECRET" },
|
|
95
|
+
available: () => Boolean(process.env.RUNWAYML_API_SECRET),
|
|
96
|
+
models: RUNWAY_MODELS,
|
|
97
|
+
validate: validateRunwaySpec,
|
|
98
|
+
estimateCost: estimateRunwayCost,
|
|
99
|
+
submit: submitRunway,
|
|
100
|
+
poll: pollRunway,
|
|
101
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { fetchProviderJson, findModel, noKnownUsdEstimate } from "./shared.mjs";
|
|
2
|
+
|
|
3
|
+
const API = "https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks";
|
|
4
|
+
|
|
5
|
+
export const SEEDANCE_MODELS = Object.freeze([
|
|
6
|
+
{
|
|
7
|
+
id: "dreamina-seedance-2-0-260128",
|
|
8
|
+
label: "Seedance 2.0",
|
|
9
|
+
durations: Array.from({ length: 12 }, (_, index) => index + 4),
|
|
10
|
+
resolutions: ["480p", "720p", "1080p", "4k"],
|
|
11
|
+
capabilities: { prompt: true, startFrame: true, endFrame: true, referenceVideo: true, cameraControl: false, audio: true },
|
|
12
|
+
},
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const authHeaders = (apiKey) => ({ Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" });
|
|
16
|
+
const modelOf = (id) => findModel(SEEDANCE_MODELS, id, "Seedance");
|
|
17
|
+
|
|
18
|
+
export function validateSeedanceSpec(spec) {
|
|
19
|
+
const model = modelOf(spec.model);
|
|
20
|
+
const errors = [];
|
|
21
|
+
const warnings = [];
|
|
22
|
+
if (!spec.prompt?.trim()) errors.push("prompt is required");
|
|
23
|
+
if (!model.durations.includes(spec.durationSeconds)) errors.push(`${model.label} duration must be an integer from 4 to 15 seconds`);
|
|
24
|
+
if (!model.resolutions.includes(spec.resolution)) errors.push(`${model.label} resolution must be ${model.resolutions.join(", ")}`);
|
|
25
|
+
if (!spec.conditioning?.startFrame && !spec.conditioning?.referenceVideo) errors.push(`${model.label} requires a staged start frame or reference video`);
|
|
26
|
+
if (spec.conditioning?.referenceVideo && (spec.conditioning?.startFrame || spec.conditioning?.endFrame)) errors.push("Seedance first/last-frame and reference-video modes cannot be mixed");
|
|
27
|
+
if (spec.conditioning?.referenceVideo && !/^https?:\/\/|^asset:\/\//.test(spec.conditioning.referenceVideo)) errors.push("Seedance reference video must be a public URL or ModelArk asset URI");
|
|
28
|
+
if (spec.seed !== null && spec.seed !== undefined) warnings.push("Seedance 2.0 does not support seed; it will not be sent");
|
|
29
|
+
if (spec.camera) warnings.push("Seedance 2.0 has no verified camera-trajectory field; staged frames and composed prompt will carry the camera direction");
|
|
30
|
+
return { ok: errors.length === 0, errors, warnings, model };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mapSeedanceRequest(spec) {
|
|
34
|
+
const verdict = validateSeedanceSpec(spec);
|
|
35
|
+
if (!verdict.ok) throw new Error(verdict.errors[0]);
|
|
36
|
+
const content = [{ type: "text", text: spec.prompt }];
|
|
37
|
+
if (spec.conditioning.startFrame) content.push({ type: "image_url", image_url: { url: spec.conditioning.startFrame }, role: "first_frame" });
|
|
38
|
+
if (spec.conditioning.endFrame) content.push({ type: "image_url", image_url: { url: spec.conditioning.endFrame }, role: "last_frame" });
|
|
39
|
+
if (spec.conditioning.referenceVideo) content.push({ type: "video_url", video_url: { url: spec.conditioning.referenceVideo }, role: "reference_video" });
|
|
40
|
+
return {
|
|
41
|
+
model: verdict.model.id,
|
|
42
|
+
content,
|
|
43
|
+
resolution: spec.resolution,
|
|
44
|
+
ratio: spec.aspectRatio,
|
|
45
|
+
duration: spec.durationSeconds,
|
|
46
|
+
generate_audio: true,
|
|
47
|
+
watermark: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function submitSeedance(spec, { apiKey = process.env.ARK_API_KEY, fetchImpl = fetch, signal, ...retryOptions } = {}) {
|
|
52
|
+
if (!apiKey) throw new Error("ARK_API_KEY is not configured");
|
|
53
|
+
const verdict = validateSeedanceSpec(spec);
|
|
54
|
+
if (!verdict.ok) throw new Error(verdict.errors[0]);
|
|
55
|
+
const { response, payload } = await fetchProviderJson(API, { method: "POST", headers: authHeaders(apiKey), body: JSON.stringify(mapSeedanceRequest(spec)) }, { fetchImpl, signal, ...retryOptions });
|
|
56
|
+
if (!response.ok) throw new Error(`Seedance submit failed (HTTP ${response.status})`);
|
|
57
|
+
if (!payload.id) throw new Error("Seedance submit response has no task id");
|
|
58
|
+
return { providerJobId: payload.id, warnings: verdict.warnings };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function pollSeedance(providerJobId, { apiKey = process.env.ARK_API_KEY, fetchImpl = fetch, signal, ...retryOptions } = {}) {
|
|
62
|
+
if (!apiKey) throw new Error("ARK_API_KEY is not configured");
|
|
63
|
+
const { response, payload } = await fetchProviderJson(`${API}/${encodeURIComponent(providerJobId)}`, { headers: authHeaders(apiKey) }, { fetchImpl, signal, ...retryOptions });
|
|
64
|
+
if (!response.ok) throw new Error(`Seedance poll failed (HTTP ${response.status})`);
|
|
65
|
+
const remote = String(payload.status || "").toLowerCase();
|
|
66
|
+
const status = remote === "succeeded" ? "succeeded" : ["failed", "expired", "canceled", "cancelled"].includes(remote) ? "failed" : "processing";
|
|
67
|
+
return { status, progress: null, outputUrl: status === "succeeded" ? payload.content?.video_url ?? null : null, failure: status === "failed" ? payload.error?.message || `generation ${remote || "failed"}` : null, raw: payload };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function cancelSeedance(providerJobId, { apiKey = process.env.ARK_API_KEY, fetchImpl = fetch, signal, ...retryOptions } = {}) {
|
|
71
|
+
if (!apiKey) throw new Error("ARK_API_KEY is not configured");
|
|
72
|
+
const { response } = await fetchProviderJson(`${API}/${encodeURIComponent(providerJobId)}`, { method: "DELETE", headers: authHeaders(apiKey) }, { fetchImpl, signal, ...retryOptions });
|
|
73
|
+
if (!response.ok) throw new Error(`Seedance cancel failed (HTTP ${response.status})`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const seedanceProvider = Object.freeze({
|
|
77
|
+
id: "seedance",
|
|
78
|
+
label: "Seedance / BytePlus ModelArk",
|
|
79
|
+
auth: { type: "bearer-env", env: "ARK_API_KEY" },
|
|
80
|
+
available: () => Boolean(process.env.ARK_API_KEY),
|
|
81
|
+
models: SEEDANCE_MODELS,
|
|
82
|
+
validate: validateSeedanceSpec,
|
|
83
|
+
estimateCost: noKnownUsdEstimate,
|
|
84
|
+
submit: submitSeedance,
|
|
85
|
+
poll: pollSeedance,
|
|
86
|
+
cancel: cancelSeedance,
|
|
87
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
2
|
+
|
|
3
|
+
export function findModel(models, id, providerLabel) {
|
|
4
|
+
if (typeof id !== "string" || !id) throw new Error("model is required");
|
|
5
|
+
const model = models.find((candidate) => candidate.id === id);
|
|
6
|
+
if (!model) throw new Error(`unsupported ${providerLabel} model: ${id}`);
|
|
7
|
+
return model;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function fetchProviderJson(url, options, {
|
|
11
|
+
fetchImpl = fetch,
|
|
12
|
+
signal,
|
|
13
|
+
timeoutMs = 15000,
|
|
14
|
+
retries = 3,
|
|
15
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
16
|
+
random = Math.random,
|
|
17
|
+
} = {}) {
|
|
18
|
+
let response;
|
|
19
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
20
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
21
|
+
const attemptSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
22
|
+
try {
|
|
23
|
+
response = await fetchImpl(url, { ...options, signal: attemptSignal });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (signal?.aborted || attempt === retries) throw error;
|
|
26
|
+
await sleep(250 * (2 ** attempt) * (1 + random() * 0.5));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!RETRYABLE.has(response.status) || attempt === retries) break;
|
|
30
|
+
await response.body?.cancel().catch(() => {});
|
|
31
|
+
await sleep(250 * (2 ** attempt) * (1 + random() * 0.5));
|
|
32
|
+
}
|
|
33
|
+
const payload = await response.json().catch(() => ({}));
|
|
34
|
+
return { response, payload };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function imageForVertex(value) {
|
|
38
|
+
if (typeof value !== "string" || !value) return null;
|
|
39
|
+
const data = value.match(/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/);
|
|
40
|
+
if (data) return { bytesBase64Encoded: data[2], mimeType: data[1] };
|
|
41
|
+
if (value.startsWith("gs://")) {
|
|
42
|
+
const extension = value.match(/\.(png|webp|jpe?g)(?:$|[?#])/i)?.[1]?.toLowerCase();
|
|
43
|
+
const mimeType = extension === "png" ? "image/png" : extension === "webp" ? "image/webp" : "image/jpeg";
|
|
44
|
+
return { gcsUri: value, mimeType };
|
|
45
|
+
}
|
|
46
|
+
throw new Error("Veo conditioning images must be data URIs or gs:// Cloud Storage URIs");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const noKnownUsdEstimate = () => null;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fetchProviderJson, findModel, imageForVertex, noKnownUsdEstimate } from "./shared.mjs";
|
|
2
|
+
|
|
3
|
+
export const VEO_MODELS = Object.freeze([
|
|
4
|
+
{ id: "veo-3.1-generate-001", label: "Google Veo 3.1", durations: [4, 6, 8], resolutions: ["720p", "1080p"], capabilities: { prompt: true, startFrame: true, endFrame: true, referenceVideo: false, cameraControl: false, audio: true } },
|
|
5
|
+
{ id: "veo-3.1-fast-generate-001", label: "Google Veo 3.1 Fast", durations: [4, 6, 8], resolutions: ["720p", "1080p"], capabilities: { prompt: true, startFrame: true, endFrame: true, referenceVideo: false, cameraControl: false, audio: true } },
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const modelOf = (id) => findModel(VEO_MODELS, id, "Veo");
|
|
9
|
+
const authHeaders = (token) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" });
|
|
10
|
+
const endpoint = ({ project, location, model }) => `https://${location}-aiplatform.googleapis.com/v1/projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/publishers/google/models/${encodeURIComponent(model)}`;
|
|
11
|
+
|
|
12
|
+
export function validateVeoSpec(spec) {
|
|
13
|
+
const model = modelOf(spec.model);
|
|
14
|
+
const errors = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
if (!spec.prompt?.trim()) errors.push("prompt is required");
|
|
17
|
+
if (!model.durations.includes(spec.durationSeconds)) errors.push(`${model.label} duration must be 4, 6, or 8 seconds`);
|
|
18
|
+
if (!model.resolutions.includes(spec.resolution)) errors.push(`${model.label} resolution must be 720p or 1080p`);
|
|
19
|
+
if (!spec.conditioning?.startFrame) errors.push(`${model.label} requires a staged start frame`);
|
|
20
|
+
for (const frame of [spec.conditioning?.startFrame, spec.conditioning?.endFrame].filter(Boolean)) {
|
|
21
|
+
try { imageForVertex(frame); } catch (error) { errors.push(error.message); }
|
|
22
|
+
}
|
|
23
|
+
if (spec.conditioning?.referenceVideo) warnings.push("Veo 3.1 reference-video input is not supported by this adapter and will not be sent");
|
|
24
|
+
if (spec.camera) warnings.push("Veo has no camera-trajectory field; staged frames and composed prompt will carry the authored camera move");
|
|
25
|
+
return { ok: errors.length === 0, errors, warnings, model };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mapVeoRequest(spec) {
|
|
29
|
+
const verdict = validateVeoSpec(spec);
|
|
30
|
+
if (!verdict.ok) throw new Error(verdict.errors[0]);
|
|
31
|
+
const instance = { prompt: spec.prompt, image: imageForVertex(spec.conditioning.startFrame) };
|
|
32
|
+
if (spec.conditioning.endFrame) instance.lastFrame = imageForVertex(spec.conditioning.endFrame);
|
|
33
|
+
const parameters = { aspectRatio: spec.aspectRatio, durationSeconds: spec.durationSeconds, resolution: spec.resolution, sampleCount: 1, generateAudio: true };
|
|
34
|
+
if (Number.isInteger(spec.seed)) parameters.seed = spec.seed;
|
|
35
|
+
return { instances: [instance], parameters };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function config(options) {
|
|
39
|
+
return {
|
|
40
|
+
token: options.token ?? process.env.GOOGLE_CLOUD_ACCESS_TOKEN,
|
|
41
|
+
project: options.project ?? process.env.GOOGLE_CLOUD_PROJECT,
|
|
42
|
+
location: options.location ?? process.env.GOOGLE_CLOUD_LOCATION ?? "us-central1",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function submitVeo(spec, { fetchImpl = fetch, signal, ...options } = {}) {
|
|
47
|
+
const cfg = config(options);
|
|
48
|
+
if (!cfg.token || !cfg.project) throw new Error("GOOGLE_CLOUD_ACCESS_TOKEN and GOOGLE_CLOUD_PROJECT are required");
|
|
49
|
+
const verdict = validateVeoSpec(spec);
|
|
50
|
+
if (!verdict.ok) throw new Error(verdict.errors[0]);
|
|
51
|
+
const url = `${endpoint({ ...cfg, model: spec.model })}:predictLongRunning`;
|
|
52
|
+
const { response, payload } = await fetchProviderJson(url, { method: "POST", headers: authHeaders(cfg.token), body: JSON.stringify(mapVeoRequest(spec)) }, { fetchImpl, signal, ...options });
|
|
53
|
+
if (!response.ok) throw new Error(`Veo submit failed (HTTP ${response.status})`);
|
|
54
|
+
if (!payload.name) throw new Error("Veo submit response has no operation name");
|
|
55
|
+
return { providerJobId: payload.name, warnings: verdict.warnings };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function pollVeo(providerJobId, { model = "veo-3.1-generate-001", fetchImpl = fetch, signal, ...options } = {}) {
|
|
59
|
+
const cfg = config(options);
|
|
60
|
+
if (!cfg.token || !cfg.project) throw new Error("GOOGLE_CLOUD_ACCESS_TOKEN and GOOGLE_CLOUD_PROJECT are required");
|
|
61
|
+
modelOf(model);
|
|
62
|
+
const url = `${endpoint({ ...cfg, model })}:fetchPredictOperation`;
|
|
63
|
+
const { response, payload } = await fetchProviderJson(url, { method: "POST", headers: authHeaders(cfg.token), body: JSON.stringify({ operationName: providerJobId }) }, { fetchImpl, signal, ...options });
|
|
64
|
+
if (!response.ok) throw new Error(`Veo poll failed (HTTP ${response.status})`);
|
|
65
|
+
if (!payload.done) return { status: "processing", progress: null, outputUrl: null, failure: null, raw: payload };
|
|
66
|
+
if (payload.error) return { status: "failed", progress: null, outputUrl: null, failure: payload.error.message || "generation failed", raw: payload };
|
|
67
|
+
const video = payload.response?.videos?.[0];
|
|
68
|
+
const outputUrl = video?.bytesBase64Encoded ? `data:${video.mimeType || "video/mp4"};base64,${video.bytesBase64Encoded}` : video?.gcsUri ?? null;
|
|
69
|
+
if (!outputUrl) return { status: "failed", progress: null, outputUrl: null, failure: "Veo completed without a video result", raw: payload };
|
|
70
|
+
if (outputUrl.startsWith("gs://")) return { status: "failed", progress: null, outputUrl: null, failure: "Veo returned a private gs:// result; omit storageUri so the bridge can save returned video bytes locally", raw: payload };
|
|
71
|
+
return { status: "succeeded", progress: 1, outputUrl, failure: null, raw: payload };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const veoProvider = Object.freeze({
|
|
75
|
+
id: "veo",
|
|
76
|
+
label: "Google Veo / Vertex AI",
|
|
77
|
+
auth: { type: "oauth-env", env: "GOOGLE_CLOUD_ACCESS_TOKEN", projectEnv: "GOOGLE_CLOUD_PROJECT" },
|
|
78
|
+
available: () => Boolean(process.env.GOOGLE_CLOUD_ACCESS_TOKEN && process.env.GOOGLE_CLOUD_PROJECT),
|
|
79
|
+
models: VEO_MODELS,
|
|
80
|
+
validate: validateVeoSpec,
|
|
81
|
+
estimateCost: noKnownUsdEstimate,
|
|
82
|
+
submit: submitVeo,
|
|
83
|
+
poll: (providerJobId, options) => pollVeo(providerJobId, options),
|
|
84
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Archive GitHub repository traffic into .metrics/.
|
|
3
|
+
//
|
|
4
|
+
// GitHub keeps traffic for 14 days. That is shorter than the time it takes to
|
|
5
|
+
// tell whether a launch worked, so this snapshots the numbers and merges them
|
|
6
|
+
// into a permanent time series.
|
|
7
|
+
//
|
|
8
|
+
// The traffic endpoints need repository admin rights, which the Actions
|
|
9
|
+
// GITHUB_TOKEN does not have. So this runs wherever a real credential lives:
|
|
10
|
+
// pass one in GITHUB_TOKEN / GH_TOKEN, or let it fall back to the token git
|
|
11
|
+
// already stores for github.com.
|
|
12
|
+
|
|
13
|
+
import { writeFile, readFile, mkdir } from "node:fs/promises";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const REPO = process.env.TRAFFIC_REPO ?? "HaD0Yun/CozyClay";
|
|
19
|
+
const OUT = path.resolve(process.env.TRAFFIC_OUT ?? ".metrics");
|
|
20
|
+
|
|
21
|
+
async function resolveToken() {
|
|
22
|
+
const fromEnv = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
23
|
+
if (fromEnv) return fromEnv;
|
|
24
|
+
|
|
25
|
+
// git credential fill speaks a key=value protocol on stdin/stdout, and it
|
|
26
|
+
// blocks until stdin is closed — so the request has to be written and the
|
|
27
|
+
// pipe ended explicitly.
|
|
28
|
+
const stdout = await new Promise((resolve) => {
|
|
29
|
+
const child = spawn("git", ["credential", "fill"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
30
|
+
let out = "";
|
|
31
|
+
const done = (value) => resolve(value);
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
child.kill();
|
|
34
|
+
done("");
|
|
35
|
+
}, 10_000);
|
|
36
|
+
child.stdout.on("data", (chunk) => (out += chunk));
|
|
37
|
+
child.on("error", () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
done("");
|
|
40
|
+
});
|
|
41
|
+
child.on("close", () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
done(out);
|
|
44
|
+
});
|
|
45
|
+
child.stdin.end(`protocol=https\nhost=github.com\npath=${REPO}.git\n\n`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const line = stdout.split("\n").find((l) => l.startsWith("password="));
|
|
49
|
+
if (!line) throw new Error("no GitHub token: set GITHUB_TOKEN or sign in to git");
|
|
50
|
+
return line.slice("password=".length);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function api(token, endpoint) {
|
|
54
|
+
const res = await fetch(`https://api.github.com/repos/${REPO}/traffic/${endpoint}`, {
|
|
55
|
+
headers: {
|
|
56
|
+
authorization: `Bearer ${token}`,
|
|
57
|
+
accept: "application/vnd.github+json",
|
|
58
|
+
"x-github-api-version": "2022-11-28",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const body = await res.text();
|
|
63
|
+
throw new Error(`${endpoint} -> ${res.status}: ${body.slice(0, 200)}`);
|
|
64
|
+
}
|
|
65
|
+
return res.json();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readJson(file, fallback) {
|
|
69
|
+
const p = path.join(OUT, file);
|
|
70
|
+
if (!existsSync(p)) return fallback;
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(await readFile(p, "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sortKeys = (obj) => Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
79
|
+
|
|
80
|
+
async function writeJson(file, data) {
|
|
81
|
+
await writeFile(path.join(OUT, file), JSON.stringify(sortKeys(data), null, 2) + "\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const token = await resolveToken();
|
|
85
|
+
await mkdir(OUT, { recursive: true });
|
|
86
|
+
|
|
87
|
+
const [views, clones, referrers, paths] = await Promise.all([
|
|
88
|
+
api(token, "views"),
|
|
89
|
+
api(token, "clones"),
|
|
90
|
+
api(token, "popular/referrers"),
|
|
91
|
+
api(token, "popular/paths"),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// Daily counts are keyed by date, so re-running the same day corrects the row
|
|
95
|
+
// instead of appending a duplicate.
|
|
96
|
+
const daily = await readJson("daily.json", {});
|
|
97
|
+
for (const [kind, payload] of [
|
|
98
|
+
["views", views],
|
|
99
|
+
["clones", clones],
|
|
100
|
+
]) {
|
|
101
|
+
for (const row of payload[kind] ?? []) {
|
|
102
|
+
const day = row.timestamp.slice(0, 10);
|
|
103
|
+
daily[day] ??= {};
|
|
104
|
+
daily[day][kind] = row.count;
|
|
105
|
+
daily[day][`${kind}_uniques`] = row.uniques;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await writeJson("daily.json", daily);
|
|
109
|
+
|
|
110
|
+
// Referrers and paths are 14-day rollups on GitHub's side, not per-day values,
|
|
111
|
+
// so they are kept as dated snapshots rather than merged.
|
|
112
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
113
|
+
for (const [file, payload] of [
|
|
114
|
+
["referrers.json", referrers],
|
|
115
|
+
["paths.json", paths],
|
|
116
|
+
]) {
|
|
117
|
+
const history = await readJson(file, {});
|
|
118
|
+
history[today] = payload;
|
|
119
|
+
await writeJson(file, history);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const rows = Object.entries(daily).sort(([a], [b]) => a.localeCompare(b));
|
|
123
|
+
await writeFile(
|
|
124
|
+
path.join(OUT, "daily.csv"),
|
|
125
|
+
["date,views,views_uniques,clones,clones_uniques"]
|
|
126
|
+
.concat(
|
|
127
|
+
rows.map(([day, v]) =>
|
|
128
|
+
[day, v.views ?? "", v.views_uniques ?? "", v.clones ?? "", v.clones_uniques ?? ""].join(","),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
.join("\n") + "\n",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const totalViews = rows.reduce((sum, [, v]) => sum + (v.views ?? 0), 0);
|
|
135
|
+
console.log(
|
|
136
|
+
`archived ${rows.length} days (${totalViews} views total) · top referrer: ` +
|
|
137
|
+
`${referrers[0]?.referrer ?? "none"} (${referrers[0]?.uniques ?? 0} uniques)`,
|
|
138
|
+
);
|