@mevdragon/vidfarm-devcli 0.9.0 → 0.11.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/README.md +37 -1
- package/SKILL.director.md +123 -0
- package/SKILL.platform.md +15 -2
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +550 -184
- package/dist/src/cli.js +397 -14
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/speech.js +175 -0
- package/dist/src/editor-chat.js +3 -2
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +239 -0
- package/dist/src/services/billing.js +3 -0
- package/dist/src/services/clip-curation/ffmpeg.js +59 -17
- package/dist/src/services/clip-curation/gemini.js +72 -23
- package/dist/src/services/clip-curation/hunt.js +332 -0
- package/dist/src/services/clip-curation/index.js +3 -1
- package/dist/src/services/clip-curation/local-agent.js +247 -0
- package/dist/src/services/clip-curation/refine.js +7 -29
- package/dist/src/services/clip-curation/scan.js +37 -10
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +74 -0
- package/dist/src/services/providers.js +30 -429
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/speech.js +467 -0
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
|
@@ -5,35 +5,9 @@ import { createId } from "../lib/ids.js";
|
|
|
5
5
|
import { addSeconds } from "../lib/time.js";
|
|
6
6
|
import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
|
|
7
7
|
import sharp from "sharp";
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
export
|
|
11
|
-
}
|
|
12
|
-
export class ProviderQuotaExceededError extends Error {
|
|
13
|
-
}
|
|
14
|
-
export class ProviderWaitExceededError extends Error {
|
|
15
|
-
}
|
|
16
|
-
export class ProviderLeaseTimeoutError extends Error {
|
|
17
|
-
retryAfterSeconds;
|
|
18
|
-
constructor(message, retryAfterSeconds = 5) {
|
|
19
|
-
super(message);
|
|
20
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
export class ProviderRateLimitError extends Error {
|
|
24
|
-
retryAfterSeconds;
|
|
25
|
-
constructor(message, retryAfterSeconds = 60) {
|
|
26
|
-
super(message);
|
|
27
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export class ProviderTransientError extends Error {
|
|
31
|
-
retryAfterSeconds;
|
|
32
|
-
constructor(message, retryAfterSeconds = 5) {
|
|
33
|
-
super(message);
|
|
34
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
8
|
+
import { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError, conciseProviderDetails, parseRetryAfterSeconds, rateLimitOrQuotaError } from "./provider-errors.js";
|
|
9
|
+
import { SUPPORTED_STT_MODELS, SUPPORTED_TTS_MODELS, defaultSpeechModelFor, generateSpeechWithKey, inferSpeechContentType, transcribeSpeechWithKey } from "./speech.js";
|
|
10
|
+
export { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError } from "./provider-errors.js";
|
|
37
11
|
const SUPPORTED_TEXT_MODELS = {
|
|
38
12
|
openai: new Set(["gpt-5.4"]),
|
|
39
13
|
gemini: new Set(["gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
|
|
@@ -96,16 +70,6 @@ const OPENAI_IMAGE_SIZES = new Set([
|
|
|
96
70
|
"auto"
|
|
97
71
|
]);
|
|
98
72
|
const GEMINI_IMAGE_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]);
|
|
99
|
-
const SUPPORTED_TTS_MODELS = {
|
|
100
|
-
openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
|
|
101
|
-
gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
|
|
102
|
-
openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
|
|
103
|
-
};
|
|
104
|
-
const SUPPORTED_STT_MODELS = {
|
|
105
|
-
openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
|
|
106
|
-
gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
|
|
107
|
-
openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
|
|
108
|
-
};
|
|
109
73
|
const PROVIDER_KEY_VALIDATION_MODELS = {
|
|
110
74
|
openai: "gpt-4.1-mini",
|
|
111
75
|
gemini: "gemini-3.1-flash-lite-preview",
|
|
@@ -134,48 +98,9 @@ function formatDurationSeconds(totalSeconds) {
|
|
|
134
98
|
}
|
|
135
99
|
return `${Math.max(1, Math.round(totalSeconds))} second${Math.round(totalSeconds) === 1 ? "" : "s"}`;
|
|
136
100
|
}
|
|
137
|
-
function parseRetryAfterSeconds(value) {
|
|
138
|
-
if (!value) {
|
|
139
|
-
return 60;
|
|
140
|
-
}
|
|
141
|
-
const numeric = Number(value);
|
|
142
|
-
if (Number.isFinite(numeric) && numeric > 0) {
|
|
143
|
-
return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
|
|
144
|
-
}
|
|
145
|
-
const parsedDate = Date.parse(value);
|
|
146
|
-
if (Number.isFinite(parsedDate)) {
|
|
147
|
-
return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
|
|
148
|
-
}
|
|
149
|
-
return 60;
|
|
150
|
-
}
|
|
151
|
-
function isHardQuotaError(details) {
|
|
152
|
-
const normalized = details.toLowerCase();
|
|
153
|
-
return [
|
|
154
|
-
"insufficient_quota",
|
|
155
|
-
"quota_exceeded",
|
|
156
|
-
"exceeded your current quota",
|
|
157
|
-
"billing",
|
|
158
|
-
"insufficient credits",
|
|
159
|
-
"not enough credits",
|
|
160
|
-
"credit balance",
|
|
161
|
-
"prepaid credits",
|
|
162
|
-
"resource_exhausted"
|
|
163
|
-
].some((pattern) => normalized.includes(pattern));
|
|
164
|
-
}
|
|
165
|
-
function conciseProviderDetails(details) {
|
|
166
|
-
const compact = details.replace(/\s+/g, " ").trim();
|
|
167
|
-
return compact ? `: ${compact.slice(0, 500)}` : "";
|
|
168
|
-
}
|
|
169
101
|
function conciseProviderResponse(operation, response, details) {
|
|
170
102
|
return `${operation} returned ${response.status}${conciseProviderDetails(details)}`;
|
|
171
103
|
}
|
|
172
|
-
async function rateLimitOrQuotaError(provider, response) {
|
|
173
|
-
const details = await response.text();
|
|
174
|
-
if (isHardQuotaError(details)) {
|
|
175
|
-
return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
|
|
176
|
-
}
|
|
177
|
-
return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
|
|
178
|
-
}
|
|
179
104
|
function isTransientProviderStatus(status) {
|
|
180
105
|
return status === 408 || status === 409 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
181
106
|
}
|
|
@@ -253,9 +178,6 @@ function defaultModelFor(kind, provider) {
|
|
|
253
178
|
function providerSupportsModelFrom(supported, provider, model) {
|
|
254
179
|
return Boolean(supported[provider]?.has(normalizeProviderModel(model)));
|
|
255
180
|
}
|
|
256
|
-
function defaultSpeechModelFor(kind, provider) {
|
|
257
|
-
return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
|
|
258
|
-
}
|
|
259
181
|
function uniqueProviders(requested, supported) {
|
|
260
182
|
return [
|
|
261
183
|
requested,
|
|
@@ -686,23 +608,15 @@ export class ProviderService {
|
|
|
686
608
|
}
|
|
687
609
|
};
|
|
688
610
|
}
|
|
689
|
-
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () =>
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
provider: input.provider === "openrouter" ? "openrouter" : "openai",
|
|
699
|
-
model: input.model,
|
|
700
|
-
text: input.text,
|
|
701
|
-
voice: input.voice,
|
|
702
|
-
instructions: input.instructions,
|
|
703
|
-
responseFormat: input.responseFormat,
|
|
704
|
-
apiKey: lease.secret
|
|
705
|
-
})));
|
|
611
|
+
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => generateSpeechWithKey({
|
|
612
|
+
provider: input.provider,
|
|
613
|
+
model: input.model,
|
|
614
|
+
text: input.text,
|
|
615
|
+
voice: input.voice,
|
|
616
|
+
instructions: input.instructions,
|
|
617
|
+
responseFormat: input.responseFormat,
|
|
618
|
+
apiKey: lease.secret
|
|
619
|
+
})));
|
|
706
620
|
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
707
621
|
id: createId("usage"),
|
|
708
622
|
keyId: lease.keyId,
|
|
@@ -1612,6 +1526,14 @@ export class ProviderService {
|
|
|
1612
1526
|
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
1613
1527
|
return {
|
|
1614
1528
|
text: "Mock transcription output.",
|
|
1529
|
+
language: input.language?.trim() || null,
|
|
1530
|
+
segments: input.diarize
|
|
1531
|
+
? [
|
|
1532
|
+
{ speaker: "speaker_1", start_sec: 0, end_sec: 2, text: "Mock transcription output." },
|
|
1533
|
+
{ speaker: "speaker_2", start_sec: 2, end_sec: 4, text: "Mock second narrator." }
|
|
1534
|
+
]
|
|
1535
|
+
: [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: "Mock transcription output." }],
|
|
1536
|
+
diarization: input.diarize ? "diarized" : "none",
|
|
1615
1537
|
usage: {
|
|
1616
1538
|
inputTokens: 0,
|
|
1617
1539
|
outputTokens: 0,
|
|
@@ -1619,32 +1541,16 @@ export class ProviderService {
|
|
|
1619
1541
|
}
|
|
1620
1542
|
};
|
|
1621
1543
|
}
|
|
1622
|
-
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () =>
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
? this.callOpenRouterSpeechTranscription({
|
|
1633
|
-
model: input.model,
|
|
1634
|
-
audio: input.audio,
|
|
1635
|
-
contentType: input.contentType,
|
|
1636
|
-
prompt: input.prompt,
|
|
1637
|
-
language: input.language,
|
|
1638
|
-
apiKey: lease.secret
|
|
1639
|
-
})
|
|
1640
|
-
: this.callOpenAISpeechTranscription({
|
|
1641
|
-
model: input.model,
|
|
1642
|
-
audio: input.audio,
|
|
1643
|
-
contentType: input.contentType,
|
|
1644
|
-
prompt: input.prompt,
|
|
1645
|
-
language: input.language,
|
|
1646
|
-
apiKey: lease.secret
|
|
1647
|
-
})));
|
|
1544
|
+
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => transcribeSpeechWithKey({
|
|
1545
|
+
provider: input.provider,
|
|
1546
|
+
model: input.model,
|
|
1547
|
+
audio: input.audio,
|
|
1548
|
+
contentType: input.contentType,
|
|
1549
|
+
prompt: input.prompt,
|
|
1550
|
+
language: input.language,
|
|
1551
|
+
diarize: input.diarize,
|
|
1552
|
+
apiKey: lease.secret
|
|
1553
|
+
})));
|
|
1648
1554
|
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
1649
1555
|
id: createId("usage"),
|
|
1650
1556
|
keyId: lease.keyId,
|
|
@@ -1897,118 +1803,6 @@ export class ProviderService {
|
|
|
1897
1803
|
}
|
|
1898
1804
|
return image;
|
|
1899
1805
|
}
|
|
1900
|
-
async callOpenAICompatibleSpeechGeneration(input) {
|
|
1901
|
-
const requestedFormat = input.responseFormat ?? "mp3";
|
|
1902
|
-
const usePcmBridge = input.provider === "openrouter" &&
|
|
1903
|
-
/gemini/i.test(input.model) &&
|
|
1904
|
-
requestedFormat !== "mp3";
|
|
1905
|
-
const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
|
|
1906
|
-
const endpoint = input.provider === "openrouter"
|
|
1907
|
-
? "https://openrouter.ai/api/v1/audio/speech"
|
|
1908
|
-
: "https://api.openai.com/v1/audio/speech";
|
|
1909
|
-
const response = await fetch(endpoint, {
|
|
1910
|
-
method: "POST",
|
|
1911
|
-
headers: {
|
|
1912
|
-
"Content-Type": "application/json",
|
|
1913
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
1914
|
-
},
|
|
1915
|
-
body: JSON.stringify({
|
|
1916
|
-
model: input.model,
|
|
1917
|
-
input: input.text,
|
|
1918
|
-
voice: input.voice ?? "alloy",
|
|
1919
|
-
instructions: input.instructions,
|
|
1920
|
-
response_format: responseFormat
|
|
1921
|
-
})
|
|
1922
|
-
});
|
|
1923
|
-
if (response.status === 401 || response.status === 403) {
|
|
1924
|
-
throw new ProviderAuthError(`${input.provider} authentication failed`);
|
|
1925
|
-
}
|
|
1926
|
-
if (response.status === 429) {
|
|
1927
|
-
throw await rateLimitOrQuotaError(input.provider, response);
|
|
1928
|
-
}
|
|
1929
|
-
if (!response.ok) {
|
|
1930
|
-
const details = await response.text();
|
|
1931
|
-
throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1932
|
-
}
|
|
1933
|
-
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
1934
|
-
const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
|
|
1935
|
-
const contentType = usePcmBridge
|
|
1936
|
-
? "audio/wav"
|
|
1937
|
-
: response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
|
|
1938
|
-
return {
|
|
1939
|
-
bytes,
|
|
1940
|
-
contentType,
|
|
1941
|
-
usage: {
|
|
1942
|
-
inputTokens: 0,
|
|
1943
|
-
outputTokens: 0,
|
|
1944
|
-
costUsd: 0
|
|
1945
|
-
}
|
|
1946
|
-
};
|
|
1947
|
-
}
|
|
1948
|
-
async callGeminiSpeechGeneration(input) {
|
|
1949
|
-
const prompt = input.instructions?.trim()
|
|
1950
|
-
? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
|
|
1951
|
-
: input.text;
|
|
1952
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
1953
|
-
method: "POST",
|
|
1954
|
-
headers: {
|
|
1955
|
-
"Content-Type": "application/json"
|
|
1956
|
-
},
|
|
1957
|
-
body: JSON.stringify({
|
|
1958
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
1959
|
-
generationConfig: {
|
|
1960
|
-
responseModalities: ["AUDIO"],
|
|
1961
|
-
speechConfig: {
|
|
1962
|
-
voiceConfig: {
|
|
1963
|
-
prebuiltVoiceConfig: {
|
|
1964
|
-
voiceName: input.voice ?? "Kore"
|
|
1965
|
-
}
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
})
|
|
1970
|
-
});
|
|
1971
|
-
if (response.status === 401 || response.status === 403) {
|
|
1972
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
1973
|
-
}
|
|
1974
|
-
if (response.status === 429) {
|
|
1975
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
1976
|
-
}
|
|
1977
|
-
if (!response.ok) {
|
|
1978
|
-
const details = await response.text();
|
|
1979
|
-
throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1980
|
-
}
|
|
1981
|
-
const data = await response.json();
|
|
1982
|
-
const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
|
|
1983
|
-
const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
|
|
1984
|
-
if (!encoded) {
|
|
1985
|
-
throw new Error("gemini speech generation returned no audio payload");
|
|
1986
|
-
}
|
|
1987
|
-
const rawBytes = Buffer.from(encoded, "base64");
|
|
1988
|
-
const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
|
|
1989
|
-
const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
|
|
1990
|
-
const looksLikeWav = isWavBuffer(rawBytes);
|
|
1991
|
-
const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
|
|
1992
|
-
const shouldWrapPcm = Boolean(pcmRateMatch ||
|
|
1993
|
-
(!looksLikeWav && !looksLikeMp3));
|
|
1994
|
-
const bytes = shouldWrapPcm
|
|
1995
|
-
? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
|
|
1996
|
-
: rawBytes;
|
|
1997
|
-
const contentType = shouldWrapPcm
|
|
1998
|
-
? "audio/wav"
|
|
1999
|
-
: looksLikeWav
|
|
2000
|
-
? "audio/wav"
|
|
2001
|
-
: "audio/mpeg";
|
|
2002
|
-
return {
|
|
2003
|
-
bytes,
|
|
2004
|
-
contentType,
|
|
2005
|
-
usage: {
|
|
2006
|
-
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
2007
|
-
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
2008
|
-
costUsd: 0
|
|
2009
|
-
}
|
|
2010
|
-
};
|
|
2011
|
-
}
|
|
2012
1806
|
async callOpenAICompatibleLayoutAnalysis(input) {
|
|
2013
1807
|
const endpoint = input.provider === "openrouter"
|
|
2014
1808
|
? "https://openrouter.ai/api/v1/chat/completions"
|
|
@@ -2112,128 +1906,6 @@ export class ProviderService {
|
|
|
2112
1906
|
const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
|
|
2113
1907
|
return String(text);
|
|
2114
1908
|
}
|
|
2115
|
-
async callOpenAISpeechTranscription(input) {
|
|
2116
|
-
const contentType = input.contentType ?? "audio/mpeg";
|
|
2117
|
-
const form = new FormData();
|
|
2118
|
-
form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
|
|
2119
|
-
form.set("model", input.model);
|
|
2120
|
-
form.set("response_format", "json");
|
|
2121
|
-
if (input.prompt) {
|
|
2122
|
-
form.set("prompt", input.prompt);
|
|
2123
|
-
}
|
|
2124
|
-
if (input.language) {
|
|
2125
|
-
form.set("language", input.language);
|
|
2126
|
-
}
|
|
2127
|
-
const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
2128
|
-
method: "POST",
|
|
2129
|
-
headers: {
|
|
2130
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
2131
|
-
},
|
|
2132
|
-
body: form
|
|
2133
|
-
});
|
|
2134
|
-
if (response.status === 401 || response.status === 403) {
|
|
2135
|
-
throw new ProviderAuthError("openai authentication failed");
|
|
2136
|
-
}
|
|
2137
|
-
if (response.status === 429) {
|
|
2138
|
-
throw await rateLimitOrQuotaError("openai", response);
|
|
2139
|
-
}
|
|
2140
|
-
if (!response.ok) {
|
|
2141
|
-
const details = await response.text();
|
|
2142
|
-
throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2143
|
-
}
|
|
2144
|
-
const data = await response.json();
|
|
2145
|
-
return {
|
|
2146
|
-
text: String(data.text ?? ""),
|
|
2147
|
-
usage: {
|
|
2148
|
-
inputTokens: Number(data.usage?.input_tokens ?? 0),
|
|
2149
|
-
outputTokens: Number(data.usage?.output_tokens ?? 0),
|
|
2150
|
-
costUsd: Number(data.usage?.cost ?? 0)
|
|
2151
|
-
}
|
|
2152
|
-
};
|
|
2153
|
-
}
|
|
2154
|
-
async callOpenRouterSpeechTranscription(input) {
|
|
2155
|
-
const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
|
|
2156
|
-
method: "POST",
|
|
2157
|
-
headers: {
|
|
2158
|
-
"Content-Type": "application/json",
|
|
2159
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
2160
|
-
},
|
|
2161
|
-
body: JSON.stringify({
|
|
2162
|
-
model: input.model,
|
|
2163
|
-
input_audio: {
|
|
2164
|
-
data: Buffer.from(input.audio).toString("base64"),
|
|
2165
|
-
format: audioFormatFromContentType(input.contentType)
|
|
2166
|
-
},
|
|
2167
|
-
...(input.prompt ? { prompt: input.prompt } : {}),
|
|
2168
|
-
...(input.language ? { language: input.language } : {})
|
|
2169
|
-
})
|
|
2170
|
-
});
|
|
2171
|
-
if (response.status === 401 || response.status === 403) {
|
|
2172
|
-
throw new ProviderAuthError("openrouter authentication failed");
|
|
2173
|
-
}
|
|
2174
|
-
if (response.status === 429) {
|
|
2175
|
-
throw await rateLimitOrQuotaError("openrouter", response);
|
|
2176
|
-
}
|
|
2177
|
-
if (!response.ok) {
|
|
2178
|
-
const details = await response.text();
|
|
2179
|
-
throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2180
|
-
}
|
|
2181
|
-
const data = await response.json();
|
|
2182
|
-
return {
|
|
2183
|
-
text: String(data.text ?? ""),
|
|
2184
|
-
usage: {
|
|
2185
|
-
inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
|
|
2186
|
-
outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
|
|
2187
|
-
costUsd: Number(data.usage?.cost ?? 0)
|
|
2188
|
-
}
|
|
2189
|
-
};
|
|
2190
|
-
}
|
|
2191
|
-
async callGeminiSpeechTranscription(input) {
|
|
2192
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
2193
|
-
method: "POST",
|
|
2194
|
-
headers: {
|
|
2195
|
-
"Content-Type": "application/json"
|
|
2196
|
-
},
|
|
2197
|
-
body: JSON.stringify({
|
|
2198
|
-
contents: [{
|
|
2199
|
-
parts: [
|
|
2200
|
-
{
|
|
2201
|
-
inline_data: {
|
|
2202
|
-
mime_type: input.contentType ?? "audio/mpeg",
|
|
2203
|
-
data: Buffer.from(input.audio).toString("base64")
|
|
2204
|
-
}
|
|
2205
|
-
},
|
|
2206
|
-
{
|
|
2207
|
-
text: buildGeminiTranscriptionPrompt(input.prompt, input.language)
|
|
2208
|
-
}
|
|
2209
|
-
]
|
|
2210
|
-
}],
|
|
2211
|
-
generationConfig: {
|
|
2212
|
-
temperature: 0,
|
|
2213
|
-
responseMimeType: "text/plain"
|
|
2214
|
-
}
|
|
2215
|
-
})
|
|
2216
|
-
});
|
|
2217
|
-
if (response.status === 401 || response.status === 403) {
|
|
2218
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
2219
|
-
}
|
|
2220
|
-
if (response.status === 429) {
|
|
2221
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
2222
|
-
}
|
|
2223
|
-
if (!response.ok) {
|
|
2224
|
-
const details = await response.text();
|
|
2225
|
-
throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2226
|
-
}
|
|
2227
|
-
const data = await response.json();
|
|
2228
|
-
return {
|
|
2229
|
-
text: extractGeminiText(data),
|
|
2230
|
-
usage: {
|
|
2231
|
-
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
2232
|
-
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
2233
|
-
costUsd: 0
|
|
2234
|
-
}
|
|
2235
|
-
};
|
|
2236
|
-
}
|
|
2237
1909
|
async callProvider(input) {
|
|
2238
1910
|
if (input.provider === "gemini") {
|
|
2239
1911
|
return this.callGemini({
|
|
@@ -2643,77 +2315,6 @@ function assertSupportedSpeechModel(kind, provider, model) {
|
|
|
2643
2315
|
function normalizeProviderModel(model) {
|
|
2644
2316
|
return model.trim().replace(/^models\//, "");
|
|
2645
2317
|
}
|
|
2646
|
-
function inferSpeechContentType(format) {
|
|
2647
|
-
return format === "wav" ? "audio/wav" : "audio/mpeg";
|
|
2648
|
-
}
|
|
2649
|
-
function pcm16MonoToWav(pcmBuffer, sampleRate) {
|
|
2650
|
-
const header = Buffer.alloc(44);
|
|
2651
|
-
header.write("RIFF", 0, 4, "ascii");
|
|
2652
|
-
header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
|
|
2653
|
-
header.write("WAVE", 8, 4, "ascii");
|
|
2654
|
-
header.write("fmt ", 12, 4, "ascii");
|
|
2655
|
-
header.writeUInt32LE(16, 16);
|
|
2656
|
-
header.writeUInt16LE(1, 20);
|
|
2657
|
-
header.writeUInt16LE(1, 22);
|
|
2658
|
-
header.writeUInt32LE(sampleRate, 24);
|
|
2659
|
-
header.writeUInt32LE(sampleRate * 2, 28);
|
|
2660
|
-
header.writeUInt16LE(2, 32);
|
|
2661
|
-
header.writeUInt16LE(16, 34);
|
|
2662
|
-
header.write("data", 36, 4, "ascii");
|
|
2663
|
-
header.writeUInt32LE(pcmBuffer.byteLength, 40);
|
|
2664
|
-
return Buffer.concat([header, pcmBuffer]);
|
|
2665
|
-
}
|
|
2666
|
-
function isWavBuffer(buffer) {
|
|
2667
|
-
return buffer.byteLength >= 12
|
|
2668
|
-
&& buffer.subarray(0, 4).toString("ascii") === "RIFF"
|
|
2669
|
-
&& buffer.subarray(8, 12).toString("ascii") === "WAVE";
|
|
2670
|
-
}
|
|
2671
|
-
function isLikelyMp3Buffer(buffer) {
|
|
2672
|
-
if (buffer.byteLength < 3) {
|
|
2673
|
-
return false;
|
|
2674
|
-
}
|
|
2675
|
-
if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
|
|
2676
|
-
return true;
|
|
2677
|
-
}
|
|
2678
|
-
return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
|
|
2679
|
-
}
|
|
2680
|
-
function audioFormatFromContentType(contentType) {
|
|
2681
|
-
const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
|
|
2682
|
-
switch (normalized) {
|
|
2683
|
-
case "audio/wav":
|
|
2684
|
-
case "audio/x-wav":
|
|
2685
|
-
return "wav";
|
|
2686
|
-
case "audio/mp4":
|
|
2687
|
-
case "audio/m4a":
|
|
2688
|
-
return "mp4";
|
|
2689
|
-
case "audio/webm":
|
|
2690
|
-
return "webm";
|
|
2691
|
-
case "audio/ogg":
|
|
2692
|
-
return "ogg";
|
|
2693
|
-
default:
|
|
2694
|
-
return "mp3";
|
|
2695
|
-
}
|
|
2696
|
-
}
|
|
2697
|
-
function audioExtensionFromContentType(contentType) {
|
|
2698
|
-
switch (audioFormatFromContentType(contentType)) {
|
|
2699
|
-
case "wav":
|
|
2700
|
-
return "wav";
|
|
2701
|
-
case "mp4":
|
|
2702
|
-
return "m4a";
|
|
2703
|
-
case "webm":
|
|
2704
|
-
return "webm";
|
|
2705
|
-
case "ogg":
|
|
2706
|
-
return "ogg";
|
|
2707
|
-
default:
|
|
2708
|
-
return "mp3";
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
function buildGeminiTranscriptionPrompt(prompt, language) {
|
|
2712
|
-
const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
|
|
2713
|
-
return language?.trim()
|
|
2714
|
-
? `${basePrompt}\n\nExpected language: ${language.trim()}.`
|
|
2715
|
-
: basePrompt;
|
|
2716
|
-
}
|
|
2717
2318
|
function extractGeminiText(data) {
|
|
2718
2319
|
return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
|
|
2719
2320
|
}
|
|
@@ -804,6 +804,11 @@ class ServerlessRecordsServiceImpl {
|
|
|
804
804
|
}
|
|
805
805
|
async createUserTemporaryFile(input) {
|
|
806
806
|
const timestamp = nowIso();
|
|
807
|
+
const expiresAt = input.expiresAt ?? timestamp;
|
|
808
|
+
// Native DynamoDB TTL (top-level expires_at_epoch_seconds) so temp-file
|
|
809
|
+
// records auto-expire even if no list sweep ever runs; the S3 object is
|
|
810
|
+
// separately expired by the tag-scoped bucket lifecycle rule.
|
|
811
|
+
const expiresAtEpochSeconds = Math.floor(Date.parse(expiresAt) / 1000);
|
|
807
812
|
return this.putRecord("user_temporary_file", {
|
|
808
813
|
id: input.id ?? createId("tmp"),
|
|
809
814
|
customerId: input.customerId,
|
|
@@ -812,9 +817,9 @@ class ServerlessRecordsServiceImpl {
|
|
|
812
817
|
sizeBytes: input.sizeBytes ?? 0,
|
|
813
818
|
storageKey: input.storageKey,
|
|
814
819
|
folderPath: normalizeFolder(input.folderPath),
|
|
815
|
-
expiresAt
|
|
820
|
+
expiresAt,
|
|
816
821
|
createdAt: input.createdAt ?? timestamp
|
|
817
|
-
});
|
|
822
|
+
}, Number.isFinite(expiresAtEpochSeconds) ? { expiresAtEpochSeconds } : undefined);
|
|
818
823
|
}
|
|
819
824
|
async listUserTemporaryFiles(customerId) {
|
|
820
825
|
return this.listByCustomer("user_temporary_file", customerId);
|
|
@@ -1196,6 +1201,8 @@ class ServerlessRecordsServiceImpl {
|
|
|
1196
1201
|
entity_type: entityType(collection),
|
|
1197
1202
|
gsi1pk,
|
|
1198
1203
|
gsi1sk: `${String(data.updatedAt)}#${id}`,
|
|
1204
|
+
// Top-level so the table's native TTL (timeToLiveAttribute) can act on it.
|
|
1205
|
+
...(options?.expiresAtEpochSeconds ? { expires_at_epoch_seconds: options.expiresAtEpochSeconds } : {}),
|
|
1199
1206
|
data
|
|
1200
1207
|
};
|
|
1201
1208
|
// Private templates stay out of the global FEED partition (sparse gsi2):
|