@mevdragon/vidfarm-devcli 0.20.5 → 0.20.8
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/.agents/skills/editor-capabilities/SKILL.md +15 -1
- package/.agents/skills/vidfarm-director/SKILL.md +106 -0
- package/.agents/skills/vidfarm-director/recipes/find-and-fork-template.md +16 -0
- package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +13 -0
- package/.agents/skills/vidfarm-director/recipes/onboard-a-new-director.md +13 -0
- package/.agents/skills/vidfarm-director/recipes/retheme-template.md +17 -0
- package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +111 -0
- package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +243 -0
- package/.agents/skills/vidfarm-director/references/core-workflows.md +273 -0
- package/.agents/skills/vidfarm-director/references/editor-workflows.md +365 -0
- package/.agents/skills/vidfarm-director/references/onboarding.md +28 -0
- package/.agents/skills/vidfarm-director/references/primitives.md +265 -0
- package/SKILL.director.md +521 -261
- package/dist/src/cli.js +404 -96
- package/dist/src/devcli/clips.js +15 -10
- package/dist/src/devcli/doctor.js +2 -2
- package/dist/src/devcli/local-backend.js +46 -2
- package/dist/src/services/clip-curation/cost.js +5 -1
- package/dist/src/services/clip-curation/gemini.js +10 -0
- package/package.json +30 -10
- package/SKILL.platform.md +0 -432
- package/demo/README.md +0 -28
- package/demo/dist/app.css +0 -1
- package/demo/dist/app.js +0 -1850
- package/demo/dist/chunks/chunk-DXB73IDG.js +0 -1
- package/demo/dist/chunks/chunk-S7OWAJDS.js +0 -36
- package/demo/dist/chunks/chunk-VTIBZ6AN.js +0 -1
- package/demo/dist/chunks/dist-ADSJKBVE.js +0 -332
- package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +0 -1
- package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +0 -425
- package/demo/dist/chunks/lib-XAQ37YOE.js +0 -1
- package/demo/dist/chunks/src-TJ2QYA4U.js +0 -207
- package/demo/dist/favicon.ico +0 -0
- package/demo/dist/icons/timeline/audio.svg +0 -7
- package/demo/dist/icons/timeline/captions.svg +0 -5
- package/demo/dist/icons/timeline/composition.svg +0 -12
- package/demo/dist/icons/timeline/image.svg +0 -18
- package/demo/dist/icons/timeline/music.svg +0 -10
- package/demo/dist/icons/timeline/text.svg +0 -3
- package/demo/dist/index.html +0 -15
- package/dist/src/account-pages-legacy.js +0 -9396
- package/dist/src/account-pages.js +0 -61
- package/dist/src/app.js +0 -21602
- package/dist/src/composition-runtime.js +0 -1053
- package/dist/src/config.js +0 -217
- package/dist/src/context.js +0 -447
- package/dist/src/dev-app-legacy.js +0 -739
- package/dist/src/dev-app.js +0 -6
- package/dist/src/devcli/migrate-local.js +0 -140
- package/dist/src/devcli/sync.js +0 -368
- package/dist/src/domain.js +0 -5
- package/dist/src/editor-chat-history.js +0 -82
- package/dist/src/editor-chat.js +0 -756
- package/dist/src/editor-dark-theme.js +0 -1128
- package/dist/src/frontend/debug.js +0 -71
- package/dist/src/frontend/discover-client.js +0 -130
- package/dist/src/frontend/discover-store.js +0 -23
- package/dist/src/frontend/file-directory.js +0 -1018
- package/dist/src/frontend/flockposter-cache-store.js +0 -124
- package/dist/src/frontend/homepage-client.js +0 -446
- package/dist/src/frontend/homepage-shared.js +0 -201
- package/dist/src/frontend/homepage-store.js +0 -66
- package/dist/src/frontend/homepage-view.js +0 -705
- package/dist/src/frontend/page-runtime-client.js +0 -132
- package/dist/src/frontend/page-runtime-store.js +0 -9
- package/dist/src/frontend/sentry.js +0 -42
- package/dist/src/frontend/template-editor-chat.js +0 -4181
- package/dist/src/help-page.js +0 -346
- package/dist/src/homepage.js +0 -1458
- package/dist/src/index.js +0 -16
- package/dist/src/instrument.js +0 -30
- package/dist/src/landing-page.js +0 -384
- package/dist/src/page-runtime.js +0 -2
- package/dist/src/page-shell.js +0 -1452
- package/dist/src/primitive-context.js +0 -416
- package/dist/src/primitive-registry.js +0 -3940
- package/dist/src/primitive-sdk.js +0 -4
- package/dist/src/primitives/hyperframes-media.js +0 -108
- package/dist/src/react-page-shell.js +0 -35
- package/dist/src/ready-post-schedule-component.js +0 -1540
- package/dist/src/registry.js +0 -296
- package/dist/src/reskin/agency-page.js +0 -299
- package/dist/src/reskin/calendar-page.js +0 -568
- package/dist/src/reskin/chat-page.js +0 -942
- package/dist/src/reskin/discover-page.js +0 -1788
- package/dist/src/reskin/document.js +0 -1587
- package/dist/src/reskin/help-page.js +0 -357
- package/dist/src/reskin/index-page.js +0 -62
- package/dist/src/reskin/inpaint-clipper-page.js +0 -890
- package/dist/src/reskin/inpaint-page.js +0 -2554
- package/dist/src/reskin/inpaint-video-page.js +0 -1339
- package/dist/src/reskin/job-runs-page.js +0 -477
- package/dist/src/reskin/library-page.js +0 -1634
- package/dist/src/reskin/login-page.js +0 -262
- package/dist/src/reskin/portfolio-page.js +0 -687
- package/dist/src/reskin/pricing-page.js +0 -390
- package/dist/src/reskin/settings-page.js +0 -732
- package/dist/src/reskin/theme.js +0 -711
- package/dist/src/runtime.js +0 -35
- package/dist/src/services/api-call-history.js +0 -249
- package/dist/src/services/auth.js +0 -152
- package/dist/src/services/billing-pricing.js +0 -39
- package/dist/src/services/billing.js +0 -241
- package/dist/src/services/cast.js +0 -127
- package/dist/src/services/chat-threads.js +0 -92
- package/dist/src/services/clip-records.js +0 -250
- package/dist/src/services/clip-search.js +0 -77
- package/dist/src/services/clip-vectors.js +0 -125
- package/dist/src/services/composition-sanitize.js +0 -124
- package/dist/src/services/composition-watch.js +0 -79
- package/dist/src/services/elevenlabs.js +0 -222
- package/dist/src/services/file-directory.js +0 -117
- package/dist/src/services/fork-access.js +0 -93
- package/dist/src/services/fork-manifest.js +0 -42
- package/dist/src/services/ghostcut.js +0 -179
- package/dist/src/services/hyperframes.js +0 -3517
- package/dist/src/services/job-capacity.js +0 -14
- package/dist/src/services/job-logs.js +0 -197
- package/dist/src/services/jobs.js +0 -136
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/media-processing.js +0 -766
- package/dist/src/services/primitive-media-lambda.js +0 -280
- package/dist/src/services/providers.js +0 -2748
- package/dist/src/services/rate-limits.js +0 -262
- package/dist/src/services/scene-annotations.js +0 -32
- package/dist/src/services/serverless-auth.js +0 -382
- package/dist/src/services/serverless-jobs.js +0 -1084
- package/dist/src/services/serverless-provider-keys.js +0 -409
- package/dist/src/services/serverless-records.js +0 -1515
- package/dist/src/services/serverless-template-configs.js +0 -75
- package/dist/src/services/storage.js +0 -461
- package/dist/src/services/swipe-customize.js +0 -437
- package/dist/src/services/template-certification.js +0 -413
- package/dist/src/services/template-loader.js +0 -99
- package/dist/src/services/template-runtime-bundles.js +0 -217
- package/dist/src/services/template-sources.js +0 -1017
- package/dist/src/services/upstream.js +0 -248
- package/dist/src/services/video-normalization.js +0 -2
- package/dist/src/services/webhooks.js +0 -62
- package/dist/src/template-editor-pages.js +0 -2576
- package/dist/src/template-editor-shell.js +0 -2893
- package/dist/src/template-sdk.js +0 -4
- package/dist/src/worker.js +0 -17
- package/public/assets/discover-client-app.js +0 -1
- package/public/assets/file-directory-app.js +0 -3
- package/public/assets/homepage-app.js +0 -54
- package/public/assets/homepage-client-app.js +0 -80
- package/public/assets/page-runtime-client-app.js +0 -94
- package/public/assets/placeholders/scene-placeholder.png +0 -0
- package/src/assets/SELLING_AWARENESS_STAGES.md +0 -579
- package/src/assets/SELLING_WITH_HOOKS.md +0 -377
- package/src/assets/SELLING_WITH_VSLS.md +0 -606
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo-vidfarm.png +0 -0
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// ElevenLabs audio provider (music, TTS, STT) — shared by the ProviderService
|
|
2
|
-
// wallet/BYOK router and the primitive handlers. ElevenLabs is a SPECIAL
|
|
3
|
-
// audio-only provider: it never counts as a qualified text/image/video key.
|
|
4
|
-
// Two ways a call reaches here:
|
|
5
|
-
// 1. Platform key (config.ELEVENLABS_API_KEY) + wallet billing — the default
|
|
6
|
-
// "use_wallet_credits" path, so good speech/music work with zero setup.
|
|
7
|
-
// 2. The customer's own saved ElevenLabs key (use_wallet_credits: false) — no
|
|
8
|
-
// wallet charge; identity/voices are scoped to THEIR ElevenLabs account.
|
|
9
|
-
//
|
|
10
|
-
// Keep the raw request/response shapes here — one implementation for cloud and
|
|
11
|
-
// devcli, mirroring services/speech.ts for the openai/gemini/openrouter family.
|
|
12
|
-
import { ProviderAuthError, rateLimitOrQuotaError } from "./provider-errors.js";
|
|
13
|
-
import { pcm16MonoToWav } from "./speech.js";
|
|
14
|
-
const ELEVENLABS_BASE_URL = "https://api.elevenlabs.io/v1";
|
|
15
|
-
// Sensible defaults so a bare `music`/`tts` call just works.
|
|
16
|
-
export const DEFAULT_ELEVENLABS_TTS_MODEL = "eleven_multilingual_v2";
|
|
17
|
-
export const DEFAULT_ELEVENLABS_STT_MODEL = "scribe_v1";
|
|
18
|
-
export const DEFAULT_ELEVENLABS_MUSIC_MODEL = "music_v2";
|
|
19
|
-
// George — a warm, versatile narrative voice. Callers can override with any
|
|
20
|
-
// voice_id from `elevenLabsListVoices` (platform or their own account).
|
|
21
|
-
export const DEFAULT_ELEVENLABS_VOICE_ID = "JBFqnCBsd6RMkjVDRZzb";
|
|
22
|
-
export const ELEVENLABS_VOICE_LIBRARY_URL = "https://elevenlabs.io/app/voice-library";
|
|
23
|
-
// A tiny friendly-name → voice_id map so agents/users can say "sarah" instead of
|
|
24
|
-
// an opaque id. Unknown values are passed through untouched (treated as a
|
|
25
|
-
// voice_id), and the real catalog is always `elevenLabsListVoices`.
|
|
26
|
-
const ELEVENLABS_VOICE_PRESETS = {
|
|
27
|
-
george: "JBFqnCBsd6RMkjVDRZzb",
|
|
28
|
-
sarah: "EXAVITQu4vr4xnSDxMaL",
|
|
29
|
-
daniel: "onwK4e9ZLuTAKqWW03F9",
|
|
30
|
-
charlotte: "XB0fDUnXU5powFXDhCwa",
|
|
31
|
-
rachel: "21m00Tcm4TlvDq8ikWAM"
|
|
32
|
-
};
|
|
33
|
-
/** Resolve a caller-supplied voice (friendly preset name or raw id) to a voice_id. */
|
|
34
|
-
export function resolveElevenLabsVoiceId(voice) {
|
|
35
|
-
const raw = voice?.trim();
|
|
36
|
-
if (!raw) {
|
|
37
|
-
return DEFAULT_ELEVENLABS_VOICE_ID;
|
|
38
|
-
}
|
|
39
|
-
return ELEVENLABS_VOICE_PRESETS[raw.toLowerCase()] ?? raw;
|
|
40
|
-
}
|
|
41
|
-
function authHeaders(apiKey) {
|
|
42
|
-
return { "xi-api-key": apiKey };
|
|
43
|
-
}
|
|
44
|
-
async function throwForStatus(provider, operation, response) {
|
|
45
|
-
if (response.status === 401 || response.status === 403) {
|
|
46
|
-
throw new ProviderAuthError("elevenlabs authentication failed");
|
|
47
|
-
}
|
|
48
|
-
if (response.status === 429) {
|
|
49
|
-
throw await rateLimitOrQuotaError(provider, response);
|
|
50
|
-
}
|
|
51
|
-
const details = await response.text().catch(() => "");
|
|
52
|
-
throw new Error(`elevenlabs ${operation} returned ${response.status}${details ? `: ${details.slice(0, 400)}` : ""}`);
|
|
53
|
-
}
|
|
54
|
-
/** Cheap credential probe — the ElevenLabs equivalent of a chat-completion validate. */
|
|
55
|
-
export async function validateElevenLabsKey(apiKey) {
|
|
56
|
-
const response = await fetch(`${ELEVENLABS_BASE_URL}/user`, { headers: authHeaders(apiKey) });
|
|
57
|
-
if (response.ok) {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
await throwForStatus("elevenlabs", "key validation", response);
|
|
61
|
-
}
|
|
62
|
-
/** List voices available to whichever account owns `apiKey` (platform or the user's). */
|
|
63
|
-
export async function elevenLabsListVoices(apiKey) {
|
|
64
|
-
const response = await fetch(`${ELEVENLABS_BASE_URL}/voices`, { headers: authHeaders(apiKey) });
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
await throwForStatus("elevenlabs", "list voices", response);
|
|
67
|
-
}
|
|
68
|
-
const data = (await response.json());
|
|
69
|
-
return (data.voices ?? []).map((voice) => ({
|
|
70
|
-
voice_id: String(voice.voice_id ?? ""),
|
|
71
|
-
name: String(voice.name ?? "Unnamed"),
|
|
72
|
-
category: typeof voice.category === "string" ? voice.category : null,
|
|
73
|
-
description: typeof voice.description === "string" && voice.description.trim() ? voice.description.trim() : null,
|
|
74
|
-
labels: voice.labels && typeof voice.labels === "object"
|
|
75
|
-
? Object.fromEntries(Object.entries(voice.labels).map(([key, value]) => [key, String(value)]))
|
|
76
|
-
: {},
|
|
77
|
-
preview_url: typeof voice.preview_url === "string" ? voice.preview_url : null
|
|
78
|
-
})).filter((voice) => voice.voice_id);
|
|
79
|
-
}
|
|
80
|
-
/** ElevenLabs text-to-speech. `voice` is a voice_id (or a friendly preset name). */
|
|
81
|
-
export async function elevenLabsTextToSpeech(input) {
|
|
82
|
-
const voiceId = resolveElevenLabsVoiceId(input.voice);
|
|
83
|
-
const model = input.model?.trim() || DEFAULT_ELEVENLABS_TTS_MODEL;
|
|
84
|
-
const wantWav = input.responseFormat === "wav";
|
|
85
|
-
// ElevenLabs has no direct "wav" output — request raw PCM and wrap it in a WAV
|
|
86
|
-
// header, mirroring the gemini/openrouter PCM bridge in services/speech.ts.
|
|
87
|
-
const outputFormat = wantWav ? "pcm_44100" : "mp3_44100_128";
|
|
88
|
-
const voiceSettings = {};
|
|
89
|
-
if (input.stability != null)
|
|
90
|
-
voiceSettings.stability = input.stability;
|
|
91
|
-
if (input.similarityBoost != null)
|
|
92
|
-
voiceSettings.similarity_boost = input.similarityBoost;
|
|
93
|
-
if (input.style != null)
|
|
94
|
-
voiceSettings.style = input.style;
|
|
95
|
-
if (input.speed != null)
|
|
96
|
-
voiceSettings.speed = input.speed;
|
|
97
|
-
const response = await fetch(`${ELEVENLABS_BASE_URL}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${outputFormat}`, {
|
|
98
|
-
method: "POST",
|
|
99
|
-
headers: { ...authHeaders(input.apiKey), "Content-Type": "application/json" },
|
|
100
|
-
body: JSON.stringify({
|
|
101
|
-
text: input.text,
|
|
102
|
-
model_id: model,
|
|
103
|
-
...(input.languageCode ? { language_code: input.languageCode } : {}),
|
|
104
|
-
...(Object.keys(voiceSettings).length ? { voice_settings: voiceSettings } : {})
|
|
105
|
-
})
|
|
106
|
-
});
|
|
107
|
-
if (!response.ok) {
|
|
108
|
-
await throwForStatus("elevenlabs", "text-to-speech", response);
|
|
109
|
-
}
|
|
110
|
-
const raw = Buffer.from(await response.arrayBuffer());
|
|
111
|
-
const bytes = wantWav ? pcm16MonoToWav(raw, 44100) : raw;
|
|
112
|
-
return {
|
|
113
|
-
bytes,
|
|
114
|
-
contentType: wantWav ? "audio/wav" : "audio/mpeg",
|
|
115
|
-
characterCount: input.text.length,
|
|
116
|
-
usage: { inputTokens: input.text.length, outputTokens: 0, costUsd: 0 }
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
/** ElevenLabs music generation from a prompt or a hand-built composition plan. */
|
|
120
|
-
export async function elevenLabsMusicCompose(input) {
|
|
121
|
-
const model = input.model?.trim() || DEFAULT_ELEVENLABS_MUSIC_MODEL;
|
|
122
|
-
const outputFormat = input.outputFormat?.trim() || "mp3_44100_128";
|
|
123
|
-
const body = { model_id: model };
|
|
124
|
-
if (input.compositionPlan) {
|
|
125
|
-
body.composition_plan = input.compositionPlan;
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
body.prompt = input.prompt;
|
|
129
|
-
body.music_length_ms = input.musicLengthMs ?? 30000;
|
|
130
|
-
}
|
|
131
|
-
const response = await fetch(`${ELEVENLABS_BASE_URL}/music?output_format=${encodeURIComponent(outputFormat)}`, {
|
|
132
|
-
method: "POST",
|
|
133
|
-
headers: { ...authHeaders(input.apiKey), "Content-Type": "application/json" },
|
|
134
|
-
body: JSON.stringify(body)
|
|
135
|
-
});
|
|
136
|
-
if (!response.ok) {
|
|
137
|
-
await throwForStatus("elevenlabs", "music generation", response);
|
|
138
|
-
}
|
|
139
|
-
return {
|
|
140
|
-
bytes: Buffer.from(await response.arrayBuffer()),
|
|
141
|
-
contentType: "audio/mpeg",
|
|
142
|
-
musicLengthMs: input.musicLengthMs ?? 30000
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
/** ElevenLabs speech-to-text (Scribe) — native diarization + word timestamps. */
|
|
146
|
-
export async function elevenLabsSpeechToText(input) {
|
|
147
|
-
const model = input.model?.trim() || DEFAULT_ELEVENLABS_STT_MODEL;
|
|
148
|
-
const form = new FormData();
|
|
149
|
-
form.set("file", new Blob([Buffer.from(input.audio)], { type: input.contentType ?? "audio/mpeg" }), "audio");
|
|
150
|
-
form.set("model_id", model);
|
|
151
|
-
form.set("diarize", String(input.diarize !== false));
|
|
152
|
-
form.set("timestamps_granularity", "word");
|
|
153
|
-
if (input.languageCode) {
|
|
154
|
-
form.set("language_code", input.languageCode);
|
|
155
|
-
}
|
|
156
|
-
if (input.numSpeakers != null) {
|
|
157
|
-
form.set("num_speakers", String(input.numSpeakers));
|
|
158
|
-
}
|
|
159
|
-
const response = await fetch(`${ELEVENLABS_BASE_URL}/speech-to-text`, {
|
|
160
|
-
method: "POST",
|
|
161
|
-
headers: authHeaders(input.apiKey),
|
|
162
|
-
body: form
|
|
163
|
-
});
|
|
164
|
-
if (!response.ok) {
|
|
165
|
-
await throwForStatus("elevenlabs", "speech-to-text", response);
|
|
166
|
-
}
|
|
167
|
-
const data = (await response.json());
|
|
168
|
-
const words = Array.isArray(data.words) ? data.words : [];
|
|
169
|
-
const segments = groupElevenLabsWordsIntoSegments(words);
|
|
170
|
-
const speakers = new Set(segments.map((segment) => segment.speaker));
|
|
171
|
-
const audioDurationSec = words.reduce((max, word) => Math.max(max, Number(word.end ?? 0)), 0) || null;
|
|
172
|
-
return {
|
|
173
|
-
text: String(data.text ?? "").trim(),
|
|
174
|
-
language: typeof data.language_code === "string" && data.language_code.trim() ? data.language_code.trim() : null,
|
|
175
|
-
segments,
|
|
176
|
-
diarization: speakers.size > 1 ? "diarized" : "none",
|
|
177
|
-
usage: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
|
|
178
|
-
audioDurationSec
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
// Fold ElevenLabs' flat word stream (each tagged with a speaker_id) into
|
|
182
|
-
// speaker-turn segments carrying word-level timings — the shape STT callers and
|
|
183
|
-
// the caption builder already consume.
|
|
184
|
-
function groupElevenLabsWordsIntoSegments(words) {
|
|
185
|
-
const segments = [];
|
|
186
|
-
let current = null;
|
|
187
|
-
let currentWords = [];
|
|
188
|
-
const flush = () => {
|
|
189
|
-
if (current) {
|
|
190
|
-
current.text = current.text.trim();
|
|
191
|
-
if (currentWords.length) {
|
|
192
|
-
current.words = currentWords;
|
|
193
|
-
}
|
|
194
|
-
if (current.text) {
|
|
195
|
-
segments.push(current);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
current = null;
|
|
199
|
-
currentWords = [];
|
|
200
|
-
};
|
|
201
|
-
for (const word of words) {
|
|
202
|
-
if (word.type && word.type !== "word" && word.type !== "spacing") {
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
const speaker = word.speaker_id?.trim() || "speaker_1";
|
|
206
|
-
const text = String(word.text ?? "");
|
|
207
|
-
if (!current || current.speaker !== speaker) {
|
|
208
|
-
flush();
|
|
209
|
-
current = { speaker, start_sec: Number(word.start ?? 0), end_sec: Number(word.end ?? 0), text: "" };
|
|
210
|
-
currentWords = [];
|
|
211
|
-
}
|
|
212
|
-
current.text += text;
|
|
213
|
-
current.end_sec = Number(word.end ?? current.end_sec ?? 0);
|
|
214
|
-
const trimmed = text.trim();
|
|
215
|
-
if (trimmed && Number.isFinite(Number(word.start)) && Number.isFinite(Number(word.end))) {
|
|
216
|
-
currentWords.push({ text: trimmed, start_sec: Number(word.start), end_sec: Number(word.end) });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
flush();
|
|
220
|
-
return segments;
|
|
221
|
-
}
|
|
222
|
-
//# sourceMappingURL=elevenlabs.js.map
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
// Shared canonical file-directory path model.
|
|
2
|
-
//
|
|
3
|
-
// The user's files live in three *separate* backends — My Files attachments,
|
|
4
|
-
// Temp files, and the Raws (clip) library — each with its own DynamoDB records
|
|
5
|
-
// and S3 prefix. This module is the ONE place that defines the unified virtual
|
|
6
|
-
// directory that presents them as three navigable roots:
|
|
7
|
-
//
|
|
8
|
-
// /files/<subfolders…>/<name> → My Files attachments (users/<id>/files/…)
|
|
9
|
-
// /temp/<YYYY-MM-DD>/<name> → Temp files (users/<id>/temporary/…)
|
|
10
|
-
// /raws/<subfolders…>/<name> → Raws / clip library (clips/<owner>/…)
|
|
11
|
-
// /projects/<forkId>/<subpath…> → Composition forks (compositions/forks/<forkId>/…)
|
|
12
|
-
//
|
|
13
|
-
// It's a *virtual* layer: physical S3 keys are untouched. The canonical path is
|
|
14
|
-
// what the UI copies, what the chat agent navigates, and what search annotates.
|
|
15
|
-
// A file's real handle is still its backend id (attachment/temp/clip id) — the
|
|
16
|
-
// path is the human-readable navigation + display layer on top.
|
|
17
|
-
//
|
|
18
|
-
// Pure logic, no runtime deps, so both `src/app.ts` (serve) and
|
|
19
|
-
// `infra/lambda/editor-chat.ts` (cloud) import it, and the frontend explorer
|
|
20
|
-
// client mirrors the same constants.
|
|
21
|
-
export const DIRECTORY_ROOT_KEYS = ["files", "temp", "raws", "projects", "approved"];
|
|
22
|
-
export const DIRECTORY_ROOTS = [
|
|
23
|
-
{ key: "files", label: "My Files", description: "Durable personal files — brand assets, characters, saved context. Vector-searchable via notes." },
|
|
24
|
-
{ key: "temp", label: "Temp", description: "Scratch space, auto-organized by date (YYYY-MM-DD) and auto-deleted after 30 days." },
|
|
25
|
-
{ key: "raws", label: "Raws", description: "The reusable clip / source-footage library, organized into folders by source." },
|
|
26
|
-
{ key: "projects", label: "Projects", description: "Your video-editing projects — one folder per fork, holding that composition's working files (composition.html/json, cast, media) and saved versions. Read-only: browse to reference or study another fork." },
|
|
27
|
-
{ key: "approved", label: "Approved", description: "Finished, ready-to-post cuts, organized into folders (defaults to a folder per tracer/campaign)." }
|
|
28
|
-
];
|
|
29
|
-
// The attachments backend historically scopes its folders under the internal
|
|
30
|
-
// key "my_files"; the canonical public root name is "files". These two helpers
|
|
31
|
-
// bridge the canonical name and that backend scope so callers never hardcode it.
|
|
32
|
-
export const ATTACHMENTS_FOLDER_SCOPE = "my_files";
|
|
33
|
-
export function isDirectoryRoot(value) {
|
|
34
|
-
return value === "files" || value === "temp" || value === "raws" || value === "projects" || value === "approved";
|
|
35
|
-
}
|
|
36
|
-
export function directoryRootLabel(root) {
|
|
37
|
-
const info = DIRECTORY_ROOTS.find((r) => r.key === root);
|
|
38
|
-
return info ? info.label : root;
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Normalize a relative folder path: trim each segment, drop empties and any
|
|
42
|
-
* leading/trailing slashes, collapse repeats. `"/a//b/ "` → `"a/b"`.
|
|
43
|
-
*/
|
|
44
|
-
export function normalizeFolderPath(value) {
|
|
45
|
-
return String(value ?? "")
|
|
46
|
-
.split("/")
|
|
47
|
-
.map((s) => s.trim())
|
|
48
|
-
.filter(Boolean)
|
|
49
|
-
.join("/");
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Parse a canonical directory path into its root + relative folder path.
|
|
53
|
-
*
|
|
54
|
-
* A path is always interpreted as a FOLDER location (what to list / scope to):
|
|
55
|
-
* every segment after the root is treated as folder path. Files are addressed
|
|
56
|
-
* by backend id, not resolved from the trailing segment, so there's no
|
|
57
|
-
* file-vs-folder ambiguity to guess at here.
|
|
58
|
-
*
|
|
59
|
-
* "/raws/product-demos" → { root:"raws", folderPath:"product-demos" }
|
|
60
|
-
* "/" or "" → { root:null, folderPath:"" } (virtual root)
|
|
61
|
-
*/
|
|
62
|
-
export function parseDirectoryPath(input) {
|
|
63
|
-
const segments = String(input ?? "")
|
|
64
|
-
.split("/")
|
|
65
|
-
.map((s) => s.trim())
|
|
66
|
-
.filter(Boolean);
|
|
67
|
-
if (segments.length === 0)
|
|
68
|
-
return { root: null, folderPath: "", segments: [] };
|
|
69
|
-
const [head, ...rest] = segments;
|
|
70
|
-
if (!isDirectoryRoot(head))
|
|
71
|
-
return { root: null, folderPath: "", segments };
|
|
72
|
-
return { root: head, folderPath: rest.join("/"), segments: rest };
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Build a canonical directory path from parts. `name` omitted → a folder path.
|
|
76
|
-
*
|
|
77
|
-
* buildDirectoryPath("files", "logos", "logo.png") → "/files/logos/logo.png"
|
|
78
|
-
* buildDirectoryPath("raws", "demos") → "/raws/demos"
|
|
79
|
-
* buildDirectoryPath("temp") → "/temp"
|
|
80
|
-
*/
|
|
81
|
-
export function buildDirectoryPath(root, folderPath, name) {
|
|
82
|
-
const parts = [root, normalizeFolderPath(folderPath)];
|
|
83
|
-
if (name)
|
|
84
|
-
parts.push(String(name).trim());
|
|
85
|
-
return "/" + parts.filter(Boolean).join("/");
|
|
86
|
-
}
|
|
87
|
-
/** Build a folder DirectoryItem for a child folder name under a root/parent. */
|
|
88
|
-
export function folderItem(root, parentFolder, childName) {
|
|
89
|
-
const folderPath = normalizeFolderPath(parentFolder ? `${parentFolder}/${childName}` : childName);
|
|
90
|
-
return {
|
|
91
|
-
path: buildDirectoryPath(root, folderPath),
|
|
92
|
-
root,
|
|
93
|
-
folderPath,
|
|
94
|
-
name: childName,
|
|
95
|
-
kind: "folder"
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Given a flat list of items' folderPaths (+ any explicit empty-folder records)
|
|
100
|
-
* and the current folder, return the immediate CHILD folder names. Mirrors the
|
|
101
|
-
* client explorer's `immediateFolders`, kept here so server and client agree.
|
|
102
|
-
*/
|
|
103
|
-
export function immediateChildFolders(folderPaths, currentFolder) {
|
|
104
|
-
const cur = normalizeFolderPath(currentFolder);
|
|
105
|
-
const prefix = cur ? cur + "/" : "";
|
|
106
|
-
const names = new Set();
|
|
107
|
-
for (const raw of folderPaths) {
|
|
108
|
-
const n = normalizeFolderPath(raw);
|
|
109
|
-
if (!n || (cur && n === cur) || n.indexOf(prefix) !== 0)
|
|
110
|
-
continue;
|
|
111
|
-
const child = n.slice(prefix.length).split("/")[0];
|
|
112
|
-
if (child)
|
|
113
|
-
names.add(child);
|
|
114
|
-
}
|
|
115
|
-
return Array.from(names).sort();
|
|
116
|
-
}
|
|
117
|
-
//# sourceMappingURL=file-directory.js.map
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { serverlessRecords } from "./serverless-records.js";
|
|
2
|
-
export class ForkNotFoundError extends Error {
|
|
3
|
-
constructor() { super("Fork not found"); this.name = "ForkNotFoundError"; }
|
|
4
|
-
}
|
|
5
|
-
export class ForkAccessDeniedError extends Error {
|
|
6
|
-
needed;
|
|
7
|
-
constructor(needed) {
|
|
8
|
-
super(`Missing required capability: ${needed}`);
|
|
9
|
-
this.needed = needed;
|
|
10
|
-
this.name = "ForkAccessDeniedError";
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
const capabilitiesByRole = {
|
|
14
|
-
viewer: { canView: true, canEdit: false, canPublish: false },
|
|
15
|
-
editor: { canView: true, canEdit: true, canPublish: false },
|
|
16
|
-
publisher: { canView: true, canEdit: true, canPublish: true }
|
|
17
|
-
};
|
|
18
|
-
function ownerCaps() {
|
|
19
|
-
return { isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner" };
|
|
20
|
-
}
|
|
21
|
-
function fromRole(role) {
|
|
22
|
-
return {
|
|
23
|
-
isOwner: false,
|
|
24
|
-
...capabilitiesByRole[role],
|
|
25
|
-
canShare: false,
|
|
26
|
-
canDelete: false,
|
|
27
|
-
role
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
function mergeCaps(a, b) {
|
|
31
|
-
const stronger = roleStrength(a.role) >= roleStrength(b.role) ? a : b;
|
|
32
|
-
return {
|
|
33
|
-
isOwner: a.isOwner || b.isOwner,
|
|
34
|
-
canView: a.canView || b.canView,
|
|
35
|
-
canEdit: a.canEdit || b.canEdit,
|
|
36
|
-
canPublish: a.canPublish || b.canPublish,
|
|
37
|
-
canShare: a.canShare || b.canShare,
|
|
38
|
-
canDelete: a.canDelete || b.canDelete,
|
|
39
|
-
role: stronger.role
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
function roleStrength(role) {
|
|
43
|
-
switch (role) {
|
|
44
|
-
case "owner": return 4;
|
|
45
|
-
case "publisher": return 3;
|
|
46
|
-
case "editor": return 2;
|
|
47
|
-
case "viewer": return 1;
|
|
48
|
-
default: return 0;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function isExpired(expiresAt) {
|
|
52
|
-
if (!expiresAt)
|
|
53
|
-
return false;
|
|
54
|
-
return new Date(expiresAt).getTime() < Date.now();
|
|
55
|
-
}
|
|
56
|
-
export async function resolveForkAccess(input) {
|
|
57
|
-
const fork = await serverlessRecords.getCompositionFork(input.forkId);
|
|
58
|
-
if (!fork || fork.deletedAt) {
|
|
59
|
-
throw new ForkNotFoundError();
|
|
60
|
-
}
|
|
61
|
-
// Fork content lives under a public-read S3 prefix — any client with the
|
|
62
|
-
// fork_id can already fetch composition.html/json/manifest.json directly
|
|
63
|
-
// from S3. Reflect that reality in the API so read endpoints don't lie
|
|
64
|
-
// about protecting content that isn't actually protected. Edit/publish/
|
|
65
|
-
// share/delete stay gated below.
|
|
66
|
-
let caps = fromRole("viewer");
|
|
67
|
-
if (input.customerId && fork.customerId === input.customerId) {
|
|
68
|
-
caps = mergeCaps(caps, ownerCaps());
|
|
69
|
-
}
|
|
70
|
-
if (input.customerId) {
|
|
71
|
-
const allPerms = await serverlessRecords.listForkPermissions(fork.id);
|
|
72
|
-
const granted = allPerms.filter((perm) => perm.granteeType === "customer" && perm.granteeId === input.customerId && !isExpired(perm.expiresAt));
|
|
73
|
-
for (const perm of granted) {
|
|
74
|
-
caps = mergeCaps(caps, fromRole(perm.role));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (input.shareToken) {
|
|
78
|
-
const link = await serverlessRecords.getForkShareLinkByToken(input.shareToken);
|
|
79
|
-
if (link && link.forkId === fork.id && !link.revokedAt && !isExpired(link.expiresAt)) {
|
|
80
|
-
caps = mergeCaps(caps, fromRole(link.role));
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return { fork, capabilities: caps };
|
|
84
|
-
}
|
|
85
|
-
export async function assertForkAccess(input, required) {
|
|
86
|
-
const result = await resolveForkAccess(input);
|
|
87
|
-
const need = `can${required[0].toUpperCase()}${required.slice(1)}`;
|
|
88
|
-
if (!result.capabilities[need]) {
|
|
89
|
-
throw new ForkAccessDeniedError(need);
|
|
90
|
-
}
|
|
91
|
-
return result;
|
|
92
|
-
}
|
|
93
|
-
//# sourceMappingURL=fork-access.js.map
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
function buildManifest(storage, fork, target) {
|
|
2
|
-
const isWorking = target.kind === "working";
|
|
3
|
-
const htmlKey = isWorking
|
|
4
|
-
? storage.compositionForkWorkingKey(fork.id, "composition.html")
|
|
5
|
-
: storage.compositionForkVersionKey(fork.id, target.version, "composition.html");
|
|
6
|
-
const jsonKey = isWorking
|
|
7
|
-
? storage.compositionForkWorkingKey(fork.id, "composition.json")
|
|
8
|
-
: storage.compositionForkVersionKey(fork.id, target.version, "composition.json");
|
|
9
|
-
const manifestKey = isWorking
|
|
10
|
-
? storage.compositionForkWorkingKey(fork.id, "manifest.json")
|
|
11
|
-
: storage.compositionForkVersionKey(fork.id, target.version, "manifest.json");
|
|
12
|
-
const parentManifestKey = fork.parentForkId && fork.parentVersion
|
|
13
|
-
? storage.compositionForkVersionKey(fork.parentForkId, fork.parentVersion, "manifest.json")
|
|
14
|
-
: null;
|
|
15
|
-
return {
|
|
16
|
-
fork_id: fork.id,
|
|
17
|
-
customer_id: fork.customerId,
|
|
18
|
-
is_working: isWorking,
|
|
19
|
-
version: isWorking ? null : target.version,
|
|
20
|
-
latest_version: fork.latestVersion,
|
|
21
|
-
parent_fork_id: fork.parentForkId,
|
|
22
|
-
parent_version: fork.parentVersion,
|
|
23
|
-
template_id: fork.templateId,
|
|
24
|
-
visibility: fork.visibility,
|
|
25
|
-
title: fork.title,
|
|
26
|
-
composition_url: storage.getPublicUrl(htmlKey),
|
|
27
|
-
composition_json_url: storage.getPublicUrl(jsonKey),
|
|
28
|
-
manifest_url: storage.getPublicUrl(manifestKey),
|
|
29
|
-
parent_manifest_url: parentManifestKey ? storage.getPublicUrl(parentManifestKey) : null,
|
|
30
|
-
created_at: !isWorking && target.kind === "version" && target.createdAt ? target.createdAt : fork.createdAt,
|
|
31
|
-
updated_at: fork.updatedAt
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
export async function writeForkManifest(storage, fork, target) {
|
|
35
|
-
const manifest = buildManifest(storage, fork, target);
|
|
36
|
-
const key = target.kind === "working"
|
|
37
|
-
? storage.compositionForkWorkingKey(fork.id, "manifest.json")
|
|
38
|
-
: storage.compositionForkVersionKey(fork.id, target.version, "manifest.json");
|
|
39
|
-
await storage.putJson(key, manifest);
|
|
40
|
-
return manifest;
|
|
41
|
-
}
|
|
42
|
-
//# sourceMappingURL=fork-manifest.js.map
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { config } from "../config.js";
|
|
3
|
-
const BASE_URL = "https://api.zhaoli.com";
|
|
4
|
-
export function isGhostcutConfigured() {
|
|
5
|
-
return Boolean(config.GHOSTCUT_KEY && config.GHOSTCUT_SECRET);
|
|
6
|
-
}
|
|
7
|
-
// GhostCut bills per 30-second chunk with a 1-chunk minimum. A 45s clip is
|
|
8
|
-
// 2 chunks, a 90s clip is 3. Rate defaults to $0.10 per chunk but can be
|
|
9
|
-
// overridden via GHOSTCUT_USD_PER_30S.
|
|
10
|
-
export function estimateGhostcutCostUsd(durationSeconds) {
|
|
11
|
-
const usdPerChunk = config.GHOSTCUT_USD_PER_30S;
|
|
12
|
-
const positiveSeconds = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 30;
|
|
13
|
-
const chunks = Math.max(1, Math.ceil(positiveSeconds / 30));
|
|
14
|
-
const usd = Number((chunks * usdPerChunk).toFixed(4));
|
|
15
|
-
return { chunks, usd, usdPerChunk };
|
|
16
|
-
}
|
|
17
|
-
function md5Hex(input) {
|
|
18
|
-
return createHash("md5").update(input).digest("hex");
|
|
19
|
-
}
|
|
20
|
-
async function ghostcutPost(path, payload, signal) {
|
|
21
|
-
if (!config.GHOSTCUT_KEY || !config.GHOSTCUT_SECRET) {
|
|
22
|
-
throw new Error("GhostCut credentials not configured");
|
|
23
|
-
}
|
|
24
|
-
const body = JSON.stringify(payload);
|
|
25
|
-
const sign = md5Hex(md5Hex(body) + config.GHOSTCUT_SECRET);
|
|
26
|
-
const response = await fetch(`${BASE_URL}${path}`, {
|
|
27
|
-
method: "POST",
|
|
28
|
-
headers: {
|
|
29
|
-
"Content-Type": "application/json",
|
|
30
|
-
AppKey: config.GHOSTCUT_KEY,
|
|
31
|
-
AppSign: sign
|
|
32
|
-
},
|
|
33
|
-
body,
|
|
34
|
-
signal
|
|
35
|
-
});
|
|
36
|
-
if (!response.ok) {
|
|
37
|
-
throw new Error(`GhostCut HTTP ${response.status} ${response.statusText}`);
|
|
38
|
-
}
|
|
39
|
-
return (await response.json());
|
|
40
|
-
}
|
|
41
|
-
// Submits a subtitle-removal task using GhostCut's Basic text-removal model.
|
|
42
|
-
// We deliberately do NOT pass `extra_inpaint_config.model` — that would opt
|
|
43
|
-
// into one of the Advanced tiers (Lite/Pro/Full), which are:
|
|
44
|
-
// 1. More expensive per 30s chunk
|
|
45
|
-
// 2. Require `videoInpaintMasks` to be supplied — advanced_lite in particular
|
|
46
|
-
// rejects with processStatus=26 / ValidationError
|
|
47
|
-
// ("video_inpaint_masks is not set") when no mask is passed
|
|
48
|
-
// Basic is the cheapest tier and handles hardcoded TikTok-style captions fine
|
|
49
|
-
// without requiring us to hand-crop a mask region for every video.
|
|
50
|
-
//
|
|
51
|
-
// needChineseOcclude=3 = "Automatically remove moving text". Per the docs this
|
|
52
|
-
// is the smart mode that only touches AI-detected text and leaves backgrounds
|
|
53
|
-
// alone — exactly what TikTok-style dynamic burned-in captions need. Mode 1
|
|
54
|
-
// (full-screen inpainting) is more aggressive; mode 3 sidesteps that class of
|
|
55
|
-
// failure while still catching the burned-in subtitles.
|
|
56
|
-
// Returns quickly (<2s). Throws on non-1000 response codes (auth, credits, etc).
|
|
57
|
-
export async function submitSubtitleRemoval(sourceUrl, signal) {
|
|
58
|
-
const payload = {
|
|
59
|
-
urls: [sourceUrl],
|
|
60
|
-
needChineseOcclude: 3,
|
|
61
|
-
videoInpaintLang: "all"
|
|
62
|
-
};
|
|
63
|
-
const res = await ghostcutPost("/v-w-c/gateway/ve/work/free", payload, signal);
|
|
64
|
-
if (res.code !== 1000) {
|
|
65
|
-
throw new Error(`GhostCut submit failed: code=${res.code} msg=${res.msg ?? "unknown"}`);
|
|
66
|
-
}
|
|
67
|
-
const taskId = res.body?.idProject ??
|
|
68
|
-
res.body?.dataList?.[0]?.idProject ??
|
|
69
|
-
res.body?.dataList?.[0]?.id;
|
|
70
|
-
if (typeof taskId !== "number") {
|
|
71
|
-
throw new Error(`GhostCut response missing task id: ${JSON.stringify(res)}`);
|
|
72
|
-
}
|
|
73
|
-
return { taskId };
|
|
74
|
-
}
|
|
75
|
-
export async function pollStatus(taskId, signal) {
|
|
76
|
-
const res = await ghostcutPost("/v-w-c/gateway/ve/work/status", { idProjects: [taskId] }, signal);
|
|
77
|
-
if (res.code !== 1000) {
|
|
78
|
-
return { state: "failed", reason: `status code=${res.code}` };
|
|
79
|
-
}
|
|
80
|
-
const entry = res.body?.content?.[0];
|
|
81
|
-
if (!entry) {
|
|
82
|
-
return { state: "queued", progress: 0 };
|
|
83
|
-
}
|
|
84
|
-
const processStatus = Number(entry.processStatus ?? 0);
|
|
85
|
-
const progress = Number(entry.processProgress ?? 0);
|
|
86
|
-
// GhostCut's canonical live states: 1 = done, 0 = processing, -2 = queued.
|
|
87
|
-
// Everything else is a terminal error condition. Notable codes we've hit
|
|
88
|
-
// in the wild: -1/2/3 (generic failure), 73 (insufficient points — account
|
|
89
|
-
// out of credits, task will NEVER progress). Treat all non-live codes as
|
|
90
|
-
// failed so we stop polling and fall back to the original video instead
|
|
91
|
-
// of spinning forever.
|
|
92
|
-
if (processStatus === 1) {
|
|
93
|
-
const rawUrl = entry.videoUrl ?? entry.videoUrls;
|
|
94
|
-
const videoUrl = Array.isArray(rawUrl) ? String(rawUrl[0] ?? "") : String(rawUrl ?? "");
|
|
95
|
-
if (!videoUrl)
|
|
96
|
-
return { state: "failed", reason: "processStatus=1 but no videoUrl" };
|
|
97
|
-
return { state: "done", videoUrl, progress: 100 };
|
|
98
|
-
}
|
|
99
|
-
if (processStatus === 0) {
|
|
100
|
-
return { state: "processing", progress };
|
|
101
|
-
}
|
|
102
|
-
if (processStatus === -2) {
|
|
103
|
-
return { state: "queued", progress };
|
|
104
|
-
}
|
|
105
|
-
// Any other code is terminal-failure. Assemble the most helpful reason
|
|
106
|
-
// string GhostCut gives us, in priority order:
|
|
107
|
-
// 1. processStatusEnum.descriptionEn — official English label for the code
|
|
108
|
-
// 2. errorDetail — long-form failure message ("Video too short", etc.)
|
|
109
|
-
// 3. errorMsg — short label
|
|
110
|
-
// 4. compact raw entry dump — fallback so unknown codes still capture
|
|
111
|
-
// whatever fields GhostCut populated, letting us reverse-engineer
|
|
112
|
-
// new failure modes without a second API round-trip.
|
|
113
|
-
// The Appendix A enum table (Feishu-only) isn't public, so relying on
|
|
114
|
-
// GhostCut's own free-text fields is the only way to name codes like 26.
|
|
115
|
-
const enumObj = entry.processStatusEnum;
|
|
116
|
-
const enumEn = typeof enumObj?.descriptionEn === "string" ? enumObj.descriptionEn.trim() : "";
|
|
117
|
-
const errorDetail = typeof entry.errorDetail === "string" ? entry.errorDetail.trim() : "";
|
|
118
|
-
const errorMsg = typeof entry.errorMsg === "string" ? entry.errorMsg.trim() : "";
|
|
119
|
-
const parts = [];
|
|
120
|
-
if (enumEn)
|
|
121
|
-
parts.push(enumEn);
|
|
122
|
-
if (errorDetail && errorDetail !== enumEn)
|
|
123
|
-
parts.push(`detail=${errorDetail}`);
|
|
124
|
-
if (errorMsg && errorMsg !== enumEn && errorMsg !== errorDetail)
|
|
125
|
-
parts.push(`msg=${errorMsg}`);
|
|
126
|
-
if (parts.length === 0) {
|
|
127
|
-
// Nothing human-readable — dump the raw payload so operators can still see
|
|
128
|
-
// whatever GhostCut *did* send back.
|
|
129
|
-
return { state: "failed", reason: `processStatus=${processStatus} raw=${safeCompactJson(entry, 500)}` };
|
|
130
|
-
}
|
|
131
|
-
return { state: "failed", reason: `processStatus=${processStatus} ${parts.join(" ")}` };
|
|
132
|
-
}
|
|
133
|
-
// Best-effort compact stringifier — clamps the output so a huge GhostCut
|
|
134
|
-
// entry doesn't blow up the log line, and falls back to a marker string
|
|
135
|
-
// when serialization itself fails (circular refs shouldn't happen but
|
|
136
|
-
// defense-in-depth for JSON.stringify).
|
|
137
|
-
function safeCompactJson(value, maxLen) {
|
|
138
|
-
try {
|
|
139
|
-
const raw = JSON.stringify(value);
|
|
140
|
-
if (raw.length <= maxLen)
|
|
141
|
-
return raw;
|
|
142
|
-
return `${raw.slice(0, maxLen)}…(+${raw.length - maxLen}b)`;
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
return "<unserializable>";
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// Polls GhostCut until the task is done, fails, or the timeout expires.
|
|
149
|
-
// Throws on failure/timeout — callers use that to fall back to the original video.
|
|
150
|
-
export async function waitForCompletion(taskId, opts) {
|
|
151
|
-
const started = Date.now();
|
|
152
|
-
const pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
153
|
-
while (true) {
|
|
154
|
-
if (opts.signal?.aborted)
|
|
155
|
-
throw new Error("GhostCut wait aborted");
|
|
156
|
-
const remaining = opts.timeoutMs - (Date.now() - started);
|
|
157
|
-
if (remaining <= 0)
|
|
158
|
-
throw new Error(`GhostCut task ${taskId} timed out`);
|
|
159
|
-
const status = await pollStatus(taskId, opts.signal);
|
|
160
|
-
if (status.state === "done")
|
|
161
|
-
return { videoUrl: status.videoUrl };
|
|
162
|
-
if (status.state === "failed")
|
|
163
|
-
throw new Error(`GhostCut task ${taskId} failed: ${status.reason}`);
|
|
164
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remaining)));
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// Downloads the GhostCut-hosted result and mirrors it to our S3 bucket so
|
|
168
|
-
// composition HTML can point at a stable URL under our control (rather than a
|
|
169
|
-
// short-lived third-party CDN link).
|
|
170
|
-
export async function mirrorGhostcutVideoToStorage(input) {
|
|
171
|
-
const response = await fetch(input.videoUrl, { signal: input.signal });
|
|
172
|
-
if (!response.ok) {
|
|
173
|
-
throw new Error(`Failed to fetch GhostCut result: HTTP ${response.status}`);
|
|
174
|
-
}
|
|
175
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
176
|
-
const result = await input.storage.putBuffer(input.key, Buffer.from(arrayBuffer), "video/mp4");
|
|
177
|
-
return { key: result.key, publicUrl: result.url };
|
|
178
|
-
}
|
|
179
|
-
//# sourceMappingURL=ghostcut.js.map
|