vidspotai-shared 1.0.101-dev.0 → 1.0.101

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.
@@ -1 +1 @@
1
- {"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../../../src/globals/aiModels/providers/google.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,eAAO,MAAM,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,YAAY,CAAC,CA4ZpE,CAAC"}
1
+ {"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../../../src/globals/aiModels/providers/google.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,eAAO,MAAM,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,YAAY,CAAC,CA+ZpE,CAAC"}
@@ -109,12 +109,15 @@ exports.googleConfigs = {
109
109
  // throws "seed parameter is not supported in Gemini API" client-side.
110
110
  // Kept for reference; restore only under a Vertex (vertexai:true) client.
111
111
  // seed: {},
112
- // NOTE (applies to all Veo 3.x): on the Gemini API only "allow_all" is
113
- // actually honored — "allow_adult"/"dont_allow" are Veo-2 legacy and
114
- // return 400 "<value> for personGeneration is currently not supported".
115
- // allowedValues is kept as the union so validateParams doesn't hard-throw
116
- // on a planner-emitted value; google.service.ts forwards ONLY allow_all
117
- // and silently drops the rest (allow_all is the API default anyway).
112
+ // NOTE (applies to all Veo 3.x): the legal value is MODE-dependent on the
113
+ // Gemini API — "allow_all" for text-to-video and extension, "allow_adult"
114
+ // for image-to-video / interpolation / reference images. "dont_allow" is
115
+ // Veo-2 legacy and returns 400 "<value> for personGeneration is currently
116
+ // not supported". allowedValues is kept as the union so validateParams
117
+ // doesn't hard-throw on a planner-emitted value; google.service.ts picks
118
+ // the value legal for the request's mode and drops the rest.
119
+ // (Regional caveat: in EU/UK/CH/MENA, Veo 3.x allows "allow_adult" ONLY,
120
+ // for every mode — so an EU-billed key would reject allow_all outright.)
118
121
  personGeneration: { allowedValues: ["allow_all", "allow_adult", "dont_allow"] },
119
122
  promptOptimizer: {},
120
123
  },
@@ -42,32 +42,45 @@ export declare const MIN_CREDITS_PER_PAID_OP = 10;
42
42
  */
43
43
  export declare const MIN_CREDITS_PER_IMAGE_OP = 2;
44
44
  /**
45
- * Credits granted on SIGNUP. Zero since 2026-07-22 signing up no longer buys
46
- * anything.
45
+ * Credits granted on SIGNUP one full floored generation ($1.00 of provider
46
+ * cost), no card required. This is the top of the funnel: a new account can try
47
+ * the product once before being asked for anything.
47
48
  *
48
- * It used to be 10 (= one full floored generation = $1.00 of provider cost),
49
- * handed to every new account with no card, no email verification, and no
50
- * device/IP dedup. Measured against production: 23,962 users, 15,021 of whom
51
- * consumed credits, and only 88 who ever spent past the free grant — a 0.59%
52
- * conversion on roughly $15k of given-away provider cost. Combined with the
53
- * refund-on-failure path, single accounts reached 274 jobs and 100 delivered
54
- * videos on a 10-credit balance.
49
+ * Briefly set to 0 on 2026-07-22 (free generation moved behind the card-backed
50
+ * trial) and restored to 10 on 2026-07-24 the acquisition cost of a
51
+ * card-walled first run was judged worse than the give-away.
55
52
  *
56
- * The free generation now lives behind the free trial, which requires a card
57
- * (Stripe Checkout, `trialDays: 1` payment.controller.ts). Same product
58
- * experience, but the grant is tied to a payment instrument that Stripe can
59
- * fingerprint, so it cannot be farmed by making another Google account.
53
+ * Read this before raising it, because the give-away is real and measured:
54
+ * 23,962 users, 15,021 of whom consumed credits, and only 88 who ever spent
55
+ * past the free grant a 0.59% conversion on roughly $15k of provider cost.
56
+ * The grant is handed out with no card, no email verification, and no
57
+ * device/IP dedup, so it is farmable by making another Google account. The
58
+ * mitigations that make 10 tolerable are:
59
+ * - the refund-on-failure loop is closed (see failureBilling.ts) — that loop,
60
+ * not the grant itself, is how single accounts reached 274 jobs and 100
61
+ * delivered videos on a 10-credit balance;
62
+ * - the floor is exactly one generation, so a farmed account gets one video.
60
63
  *
61
- * Set this back above zero ONLY together with an anti-abuse gate (verified
62
- * email + App Check at minimum) — otherwise the farming loop reopens.
64
+ * Anything ABOVE 10 needs an anti-abuse gate first (verified email + App Check
65
+ * at minimum).
63
66
  */
64
- export declare const SIGNUP_GRANT_CREDITS = 0;
67
+ export declare const SIGNUP_GRANT_CREDITS = 10;
65
68
  /**
66
69
  * Credits granted when a card-backed free trial actually starts (the
67
- * `isTrialInvoice` branch in payment.service.handleInvoiceSucceeded). This is
68
- * the value the signup grant used to hand out for free, now earned by adding a
69
- * payment method. Deliberately equal to MIN_CREDITS_PER_PAID_OP so the trial is
70
- * exactly one full generation of any kind.
70
+ * `isTrialInvoice` branch in payment.service.handleInvoiceSucceeded).
71
+ *
72
+ * ADDITIVE since 2026-07-24: that branch applies `FieldValue.increment` to
73
+ * `credits.total` and leaves `consumed` alone, so starting the trial always
74
+ * moves available credits by exactly +10 on top of whatever is left of the
75
+ * signup grant. Signup + trial with nothing spent = 20 available.
76
+ *
77
+ * It used to overwrite (`total: 10, consumed: 0`), which was invisible while
78
+ * SIGNUP_GRANT_CREDITS was 0 but became a silent no-op for users who added a
79
+ * card without having spent their signup grant. Paid (non-trial) invoices still
80
+ * SET the balance to the plan's allowance — those are a purchase, not a bonus.
81
+ *
82
+ * Deliberately equal to MIN_CREDITS_PER_PAID_OP so the trial is exactly one
83
+ * full generation of any kind.
71
84
  */
72
85
  export declare const TRIAL_GRANT_CREDITS = 10;
73
86
  //# sourceMappingURL=creditUnit.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"creditUnit.d.ts","sourceRoot":"","sources":["../../src/globals/creditUnit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,iDAAiD;AACjD,eAAO,MAAM,cAAc,MAAM,CAAC;AAElC,0EAA0E;AAC1E,eAAO,MAAM,eAAe,QAAqB,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAE1C;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,KAA0B,CAAC"}
1
+ {"version":3,"file":"creditUnit.d.ts","sourceRoot":"","sources":["../../src/globals/creditUnit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,iDAAiD;AACjD,eAAO,MAAM,cAAc,MAAM,CAAC;AAElC,0EAA0E;AAC1E,eAAO,MAAM,eAAe,QAAqB,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAE1C;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,mBAAmB,KAA0B,CAAC"}
@@ -45,31 +45,44 @@ exports.MIN_CREDITS_PER_PAID_OP = 10;
45
45
  */
46
46
  exports.MIN_CREDITS_PER_IMAGE_OP = 2;
47
47
  /**
48
- * Credits granted on SIGNUP. Zero since 2026-07-22 signing up no longer buys
49
- * anything.
48
+ * Credits granted on SIGNUP one full floored generation ($1.00 of provider
49
+ * cost), no card required. This is the top of the funnel: a new account can try
50
+ * the product once before being asked for anything.
50
51
  *
51
- * It used to be 10 (= one full floored generation = $1.00 of provider cost),
52
- * handed to every new account with no card, no email verification, and no
53
- * device/IP dedup. Measured against production: 23,962 users, 15,021 of whom
54
- * consumed credits, and only 88 who ever spent past the free grant — a 0.59%
55
- * conversion on roughly $15k of given-away provider cost. Combined with the
56
- * refund-on-failure path, single accounts reached 274 jobs and 100 delivered
57
- * videos on a 10-credit balance.
52
+ * Briefly set to 0 on 2026-07-22 (free generation moved behind the card-backed
53
+ * trial) and restored to 10 on 2026-07-24 the acquisition cost of a
54
+ * card-walled first run was judged worse than the give-away.
58
55
  *
59
- * The free generation now lives behind the free trial, which requires a card
60
- * (Stripe Checkout, `trialDays: 1` payment.controller.ts). Same product
61
- * experience, but the grant is tied to a payment instrument that Stripe can
62
- * fingerprint, so it cannot be farmed by making another Google account.
56
+ * Read this before raising it, because the give-away is real and measured:
57
+ * 23,962 users, 15,021 of whom consumed credits, and only 88 who ever spent
58
+ * past the free grant a 0.59% conversion on roughly $15k of provider cost.
59
+ * The grant is handed out with no card, no email verification, and no
60
+ * device/IP dedup, so it is farmable by making another Google account. The
61
+ * mitigations that make 10 tolerable are:
62
+ * - the refund-on-failure loop is closed (see failureBilling.ts) — that loop,
63
+ * not the grant itself, is how single accounts reached 274 jobs and 100
64
+ * delivered videos on a 10-credit balance;
65
+ * - the floor is exactly one generation, so a farmed account gets one video.
63
66
  *
64
- * Set this back above zero ONLY together with an anti-abuse gate (verified
65
- * email + App Check at minimum) — otherwise the farming loop reopens.
67
+ * Anything ABOVE 10 needs an anti-abuse gate first (verified email + App Check
68
+ * at minimum).
66
69
  */
67
- exports.SIGNUP_GRANT_CREDITS = 0;
70
+ exports.SIGNUP_GRANT_CREDITS = 10;
68
71
  /**
69
72
  * Credits granted when a card-backed free trial actually starts (the
70
- * `isTrialInvoice` branch in payment.service.handleInvoiceSucceeded). This is
71
- * the value the signup grant used to hand out for free, now earned by adding a
72
- * payment method. Deliberately equal to MIN_CREDITS_PER_PAID_OP so the trial is
73
- * exactly one full generation of any kind.
73
+ * `isTrialInvoice` branch in payment.service.handleInvoiceSucceeded).
74
+ *
75
+ * ADDITIVE since 2026-07-24: that branch applies `FieldValue.increment` to
76
+ * `credits.total` and leaves `consumed` alone, so starting the trial always
77
+ * moves available credits by exactly +10 on top of whatever is left of the
78
+ * signup grant. Signup + trial with nothing spent = 20 available.
79
+ *
80
+ * It used to overwrite (`total: 10, consumed: 0`), which was invisible while
81
+ * SIGNUP_GRANT_CREDITS was 0 but became a silent no-op for users who added a
82
+ * card without having spent their signup grant. Paid (non-trial) invoices still
83
+ * SET the balance to the plan's allowance — those are a purchase, not a bonus.
84
+ *
85
+ * Deliberately equal to MIN_CREDITS_PER_PAID_OP so the trial is exactly one
86
+ * full generation of any kind.
74
87
  */
75
88
  exports.TRIAL_GRANT_CREDITS = exports.MIN_CREDITS_PER_PAID_OP;
@@ -1,5 +1,11 @@
1
1
  import { BaseAiGenProviderService } from "../baseAiGenProvider.service";
2
2
  import { CreditUsageParams, ImageGenerationParams, ImageGenerationResult, VideoGenerationParams, VideoGenerationResult, VideoStatusParams, VideoStatusResult } from "../types";
3
+ /**
4
+ * Map a canonical "W:H" aspect ratio to a DashScope pixel `size` string.
5
+ * Returns undefined for unparseable input so the caller simply omits `size`
6
+ * and lets DashScope apply its own default (better than sending a bad value).
7
+ */
8
+ export declare function resolveDashScopeSize(modelId: string, aspectRatio: string): string | undefined;
3
9
  export declare class AlibabaService extends BaseAiGenProviderService {
4
10
  private readonly baseUrl;
5
11
  private readonly timeout;
@@ -1 +1 @@
1
- {"version":3,"file":"alibaba.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/alibaba/alibaba.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,UAAU,CAAC;AA2DlB,qBAAa,cAAe,SAAQ,wBAAwB;IAE1D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;;IAiBjC;;;;OAIG;YACW,OAAO;IAwBf,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAiE3B,gBAAgB,CAAC,EACrB,IAAI,EACJ,cAAc,EACd,cAAyB,EACzB,QAAQ,GACT,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA0EjD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IA4EjC;;;OAGG;YACW,iBAAiB;IAuC/B;;;OAGG;YACW,kBAAkB;IAuDhC,aAAa,CAAC,EACZ,QAAQ,EACR,UAAU,EACV,WAAW,EACX,QAAQ,EACR,SAAiB,EACjB,SAAa,EACb,WAAW,GACZ,EAAE,iBAAiB,GAAG,MAAM;CAoB9B"}
1
+ {"version":3,"file":"alibaba.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/alibaba/alibaba.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAoGlB;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,GAClB,MAAM,GAAG,SAAS,CAiBpB;AAED,qBAAa,cAAe,SAAQ,wBAAwB;IAE1D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;;IAiBjC;;;;OAIG;YACW,OAAO;IAwDf,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAiE3B,gBAAgB,CAAC,EACrB,IAAI,EACJ,cAAc,EACd,cAAyB,EACzB,QAAQ,GACT,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA0EjD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IA4EjC;;;OAGG;YACW,iBAAiB;IA4C/B;;;OAGG;YACW,kBAAkB;IA6DhC,aAAa,CAAC,EACZ,QAAQ,EACR,UAAU,EACV,WAAW,EACX,QAAQ,EACR,SAAiB,EACjB,SAAa,EACb,WAAW,GACZ,EAAE,iBAAiB,GAAG,MAAM;CAoB9B"}
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AlibabaService = void 0;
7
+ exports.resolveDashScopeSize = resolveDashScopeSize;
7
8
  const axios_1 = __importDefault(require("axios"));
8
9
  const types_1 = require("../../../../globals/types");
9
10
  const baseAiGenProvider_service_1 = require("../baseAiGenProvider.service");
@@ -59,6 +60,68 @@ function isSyncMultimodalImageModel(modelId) {
59
60
  function isAsyncWanImageModel(modelId) {
60
61
  return modelId.startsWith("wan2.7-image") || modelId.startsWith("wan2.6-image");
61
62
  }
63
+ /**
64
+ * DashScope's `size` parameter is "WIDTH*HEIGHT" in PIXELS — it is NOT an
65
+ * aspect-ratio string. We used to assign `parameters.size = params.aspectRatio`
66
+ * directly, so every request with an aspect ratio shipped `size: "9:16"` and
67
+ * came back `400 InvalidParameter` (observed in prod on qwen-image-2.0-pro,
68
+ * 2026-07-26). The 400 body was also swallowed (see `request`), which is why it
69
+ * only ever surfaced as the opaque "Request failed with status code 400".
70
+ *
71
+ * Two documented preset families, distinguished by their pixel budget:
72
+ * - qwen-image-2.x → 2048×2048-class (square preset 2048*2048)
73
+ * - everything else on this endpoint (max / plus / edit / wan2.7)
74
+ * → 1328×1328-class (square preset 1328*1328)
75
+ * Values below are the documented allowed presets, so they are guaranteed-valid
76
+ * rather than inferred. Ratios with no published preset (2:3, 3:2) are computed
77
+ * from the family's square-preset AREA and rounded to a multiple of 16, which
78
+ * keeps them inside the documented total-pixel range.
79
+ */
80
+ const DASHSCOPE_SIZE_PRESETS = {
81
+ // qwen-image-2.x series
82
+ large: {
83
+ "1:1": "2048*2048",
84
+ "16:9": "2688*1536",
85
+ "9:16": "1536*2688",
86
+ "4:3": "2368*1728",
87
+ "3:4": "1728*2368",
88
+ },
89
+ // qwen-image-max / -plus / -edit, wan2.7-image
90
+ standard: {
91
+ "1:1": "1328*1328",
92
+ "16:9": "1664*928",
93
+ "9:16": "928*1664",
94
+ "4:3": "1472*1104",
95
+ "3:4": "1104*1472",
96
+ },
97
+ };
98
+ /** Round to the nearest multiple of 16 (DashScope dimensions are 16-aligned). */
99
+ function round16(n) {
100
+ return Math.max(16, Math.round(n / 16) * 16);
101
+ }
102
+ /**
103
+ * Map a canonical "W:H" aspect ratio to a DashScope pixel `size` string.
104
+ * Returns undefined for unparseable input so the caller simply omits `size`
105
+ * and lets DashScope apply its own default (better than sending a bad value).
106
+ */
107
+ function resolveDashScopeSize(modelId, aspectRatio) {
108
+ const family = modelId.startsWith("qwen-image-2") ? "large" : "standard";
109
+ const preset = DASHSCOPE_SIZE_PRESETS[family][aspectRatio];
110
+ if (preset)
111
+ return preset;
112
+ // No published preset for this ratio — derive one at the family's area.
113
+ const [wStr, hStr] = aspectRatio.split(":");
114
+ const w = Number(wStr);
115
+ const h = Number(hStr);
116
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
117
+ return undefined;
118
+ }
119
+ const squarePreset = DASHSCOPE_SIZE_PRESETS[family]["1:1"];
120
+ const side = Number(squarePreset.split("*")[0]);
121
+ const area = side * side;
122
+ const ratio = w / h;
123
+ return `${round16(Math.sqrt(area * ratio))}*${round16(Math.sqrt(area / ratio))}`;
124
+ }
62
125
  class AlibabaService extends baseAiGenProvider_service_1.BaseAiGenProviderService {
63
126
  constructor() {
64
127
  super();
@@ -93,8 +156,32 @@ class AlibabaService extends baseAiGenProvider_service_1.BaseAiGenProviderServic
93
156
  timeout: this.timeout,
94
157
  data: method === "POST" ? body : undefined,
95
158
  };
96
- const res = await axios_1.default.request(config);
97
- return res.data;
159
+ try {
160
+ const res = await axios_1.default.request(config);
161
+ return res.data;
162
+ }
163
+ catch (err) {
164
+ // DashScope puts the real reason (code + message, e.g.
165
+ // "InvalidParameter: size is invalid") in the RESPONSE BODY. Axios's own
166
+ // message is only "Request failed with status code 400", so without this
167
+ // the actual cause was lost — a prod 400 on qwen-image-2.0-pro was
168
+ // undiagnosable until the body was surfaced (2026-07-26). Re-throw with
169
+ // the provider's code/message appended so it reaches Loki/Slack and the
170
+ // stored job errorMessage.
171
+ const axiosErr = err;
172
+ const data = axiosErr.response?.data;
173
+ if (data && (data.code || data.message)) {
174
+ logger_1.logger.error("Alibaba DashScope request failed", {
175
+ url,
176
+ status: axiosErr.response?.status,
177
+ code: data.code,
178
+ message: data.message,
179
+ requestId: data.request_id,
180
+ });
181
+ throw new Error(`DashScope ${axiosErr.response?.status ?? ""} ${data.code ?? ""}: ${data.message ?? axiosErr.message ?? "request failed"}`.trim());
182
+ }
183
+ throw err;
184
+ }
98
185
  }
99
186
  async generateVideo(params) {
100
187
  (0, helpers_2.validateParams)(params);
@@ -310,8 +397,13 @@ class AlibabaService extends baseAiGenProvider_service_1.BaseAiGenProviderServic
310
397
  parameters.prompt_extend = params.promptOptimizer;
311
398
  if (params.seed !== undefined)
312
399
  parameters.seed = params.seed;
313
- if (params.aspectRatio)
314
- parameters.size = params.aspectRatio;
400
+ // `size` is WIDTH*HEIGHT pixels, never a ratio string — see
401
+ // resolveDashScopeSize. An explicit imageSize (already in pixel form) wins.
402
+ if (params.aspectRatio) {
403
+ const size = resolveDashScopeSize(modelId, params.aspectRatio);
404
+ if (size)
405
+ parameters.size = size;
406
+ }
315
407
  if (params.imageSize)
316
408
  parameters.size = params.imageSize;
317
409
  const result = await this.request({ model: modelId, input, parameters }, "POST", ENDPOINT_MULTIMODAL_SYNC, false);
@@ -332,7 +424,13 @@ class AlibabaService extends baseAiGenProvider_service_1.BaseAiGenProviderServic
332
424
  input.negative_prompt = params.negativePrompt;
333
425
  const parameters = {
334
426
  n: params.numImages ?? 1,
335
- ...(params.aspectRatio ? { size: params.aspectRatio } : {}),
427
+ // Same pixel-vs-ratio rule as the sync path — see resolveDashScopeSize.
428
+ ...(params.aspectRatio
429
+ ? (() => {
430
+ const size = resolveDashScopeSize(modelId, params.aspectRatio);
431
+ return size ? { size } : {};
432
+ })()
433
+ : {}),
336
434
  ...(params.imageSize ? { size: params.imageSize } : {}),
337
435
  ...(params.seed !== undefined ? { seed: params.seed } : {}),
338
436
  ...(params.watermark !== undefined ? { watermark: params.watermark } : {}),
@@ -1 +1 @@
1
- {"version":3,"file":"bytedance.service.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/bytedance/bytedance.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAUlB,qBAAa,gBAAiB,SAAQ,wBAAwB;IAC5D,OAAO,CAAC,QAAQ,CAAC,OAAO,CACsD;IAI9E,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,WAAW;IAIb,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IA4L3B,gBAAgB,CAAC,EACrB,IAAI,EACJ,cAAc,EACd,cAAyB,GAC1B,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA4HjD,aAAa,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM;CAyEjD"}
1
+ {"version":3,"file":"bytedance.service.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/bytedance/bytedance.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAUlB,qBAAa,gBAAiB,SAAQ,wBAAwB;IAC5D,OAAO,CAAC,QAAQ,CAAC,OAAO,CACsD;IAI9E,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,WAAW;IAIb,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAqM3B,gBAAgB,CAAC,EACrB,IAAI,EACJ,cAAc,EACd,cAAyB,GAC1B,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA4HjD,aAAa,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM;CAyEjD"}
@@ -38,7 +38,15 @@ class ByteDanceService extends baseAiGenProvider_service_1.BaseAiGenProviderServ
38
38
  const modelId = modelConfig.modelId;
39
39
  const sanitizedPrompt = this.sanitizePrompt(params.prompt ?? "");
40
40
  if (!sanitizedPrompt) {
41
- throw new errors_1.UserFacingError("Your prompt could not be processed. Please use English or Chinese text and try again.");
41
+ // PROMPT_INVALID (not an uncoded throw): sanitizePrompt strips everything
42
+ // outside ASCII+CJK, so an empty result means the prompt was written in a
43
+ // script Seedance cannot accept (Cyrillic, Arabic, Devanagari, Thai, an
44
+ // all-emoji prompt, …). That is deterministic user input — it can never
45
+ // succeed on retry. Without a code, `isNonPagingCode(undefined)` is false
46
+ // and the scene-failure handler logged it at `error`, which pages Slack
47
+ // (see errorPaging.ts). PROMPT_INVALID is in NON_PAGING_ERROR_CODES, so
48
+ // this now stays a warn while still failing the scene cleanly.
49
+ throw new errors_1.UserFacingError("Your prompt could not be processed. Please use English or Chinese text and try again.", errors_1.USER_FACING_ERROR_CODES.PROMPT_INVALID);
42
50
  }
43
51
  const isV2 = this.isSeedance2(params.modelKey);
44
52
  const content = [
@@ -1 +1 @@
1
- {"version":3,"file":"google.service.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/google/google.service.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAkElB,qBAAa,aAAc,SAAQ,wBAAwB;IAKzD,OAAO,CAAC,EAAE,CAAc;IACxB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAK;;IAQ/C;;;;;;;;;OASG;IACG,YAAY,CAChB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,oBAAoB,CAAC;IAoChC;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAoCvB;;;;OAIG;YACW,kBAAkB;IAsC1B,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IA0O3B,gBAAgB,CAAC,EACrB,IAAI,EACJ,cAAc,EACd,cAAyB,GAC1B,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA8J3C,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;YAiBnB,cAAc;IAwG5B;;;;;;OAMG;IACH;;;;OAIG;IACG,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAIjC,aAAa,CAAC,EAAE,QAAQ,EAAE,QAAY,EAAE,UAAmB,EAAE,SAAiB,EAAE,SAAa,EAAE,SAAS,EAAE,WAAW,EAAE,EAAE,iBAAiB,GAAG,MAAM;CA8BpJ"}
1
+ {"version":3,"file":"google.service.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/google/google.service.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAkElB,qBAAa,aAAc,SAAQ,wBAAwB;IAKzD,OAAO,CAAC,EAAE,CAAc;IACxB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAK;;IAQ/C;;;;;;;;;OASG;IACG,YAAY,CAChB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,oBAAoB,CAAC;IAoChC;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAoCvB;;;;OAIG;YACW,kBAAkB;IAsC1B,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IA2T3B,gBAAgB,CAAC,EACrB,IAAI,EACJ,cAAc,EACd,cAAyB,GAC1B,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA8J3C,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;YAiBnB,cAAc;IAyJ5B;;;;;;OAMG;IACH;;;;OAIG;IACG,aAAa,CACjB,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAIjC,aAAa,CAAC,EAAE,QAAQ,EAAE,QAAY,EAAE,UAAmB,EAAE,SAAiB,EAAE,SAAa,EAAE,SAAS,EAAE,WAAW,EAAE,EAAE,iBAAiB,GAAG,MAAM;CA8BpJ"}
@@ -197,20 +197,54 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
197
197
  const modelConfig = aiModels_1.aiModelConfigs[params.modelKey];
198
198
  const modelId = modelConfig.modelId;
199
199
  const isVeo3_1 = VEO_3_1_MODELS.has(params.modelKey);
200
- // Gemini Veo cross-constraint: first+last-frame interpolation AND
201
- // reference images BOTH require durationSeconds=8. Sending any other
202
- // duration returns INVALID_ARGUMENT 400 "Your use case is currently not
203
- // supported." with no hint about which param caused it. Surface a
204
- // typed UserFacingError so the user/frontend can correct the input
205
- // instead of burning a provider call + opaque rejection.
206
- const needsDuration8 = !!params.lastFrameImageUrl ||
207
- (isVeo3_1 && (params.referenceImageUrls?.length ?? 0) > 0);
200
+ // ── Veo generation mode ──────────────────────────────────────────────
201
+ // Several Veo constraints are MODE-dependent (not just model-dependent),
202
+ // so resolve the mode once and drive every guard below off it. Precedence
203
+ // matches the request builder further down: video extension wins over an
204
+ // input image (they are mutually exclusive in the Veo API).
205
+ const isExtension = !!params.inputVideoUrl;
206
+ const refCount = params.referenceImageUrls?.length ?? 0;
207
+ const isImageDriven = !isExtension &&
208
+ (!!params.inputImageUrl ||
209
+ !!params.lastFrameImageUrl ||
210
+ (isVeo3_1 && refCount > 0));
211
+ // Gemini Veo cross-constraint: extension, first+last-frame interpolation
212
+ // AND reference images ALL require durationSeconds=8 (per the Veo API
213
+ // parameter table: `durationSeconds` "Must be '8' when using extension,
214
+ // reference images or with 1080p and 4k resolutions"). Sending any other
215
+ // duration returns INVALID_ARGUMENT 400 with no hint about which param
216
+ // caused it. Surface a typed UserFacingError so the user/frontend can
217
+ // correct the input instead of burning a provider call + opaque rejection.
218
+ const needsDuration8 = isExtension ||
219
+ !!params.lastFrameImageUrl ||
220
+ (isVeo3_1 && refCount > 0);
208
221
  if (needsDuration8 && params.duration !== undefined && params.duration !== 8) {
209
- const constraint = params.lastFrameImageUrl
210
- ? "first-frame + last-frame interpolation"
211
- : "reference images";
222
+ const constraint = isExtension
223
+ ? "video extension"
224
+ : params.lastFrameImageUrl
225
+ ? "first-frame + last-frame interpolation"
226
+ : "reference images";
227
+ const remedy = isExtension
228
+ ? "extend a shorter clip"
229
+ : `remove the ${params.lastFrameImageUrl ? "last-frame image" : "reference images"}`;
212
230
  throw new errors_1.UserFacingError(`Google Veo requires an 8-second duration when using ${constraint}. ` +
213
- `Please select 8s or remove the ${params.lastFrameImageUrl ? "last-frame image" : "reference images"}.`, errors_1.USER_FACING_ERROR_CODES.CAPABILITY_MISMATCH);
231
+ `Please select 8s or ${remedy}.`, errors_1.USER_FACING_ERROR_CODES.CAPABILITY_MISMATCH);
232
+ }
233
+ // Extension output is capped at 720p ("`720p` only for extension" in the
234
+ // same table). A 1080p/4k extend is a deterministic 400, so reject it here
235
+ // rather than paying for the round-trip. NOT coerced to 720p on purpose:
236
+ // resolution drives the credit price (cost.perResolution), so silently
237
+ // downgrading the output would bill the user for a tier we didn't deliver.
238
+ if (isExtension && params.resolution && params.resolution !== "720p") {
239
+ throw new errors_1.UserFacingError(`Google Veo only supports 720p when extending a video (requested ${params.resolution}). ` +
240
+ `Please select 720p to extend this clip.`, errors_1.USER_FACING_ERROR_CODES.CAPABILITY_MISMATCH);
241
+ }
242
+ // `lastFrame` is the END of an interpolation and is only meaningful with a
243
+ // START frame — the API spec states it "Must be used in combination with
244
+ // the `image` parameter." Sending it alone is a deterministic 400.
245
+ if (params.lastFrameImageUrl && !params.inputImageUrl) {
246
+ throw new errors_1.UserFacingError("Google Veo needs a starting image as well as a last-frame image to interpolate between them. " +
247
+ "Please add a first-frame image or remove the last-frame image.", errors_1.USER_FACING_ERROR_CODES.CAPABILITY_MISMATCH);
214
248
  }
215
249
  const request = {
216
250
  model: modelId,
@@ -228,18 +262,25 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
228
262
  // carried a seed. Like `generateAudio`, it is a Vertex-AI-only field.
229
263
  // Restore this line if/when this client switches to Vertex (vertexai:true).
230
264
  // ...(params.seed !== undefined ? { seed: params.seed } : {}),
231
- // personGeneration on the Gemini API Veo backend: "allow_all" is the
232
- // ONLY supported value for Veo 3.x (and it is also the default).
233
- // "allow_adult" and "dont_allow" are Veo-2-only legacy values — sending
234
- // either returns 400 "<value> for personGeneration is currently not
235
- // supported", which is NOT matched by classifyGoogleApiError and so
236
- // leaks to logger.error Slack while failing the job. Since allow_all
237
- // is the default, forward the param only when it's explicitly allow_all
238
- // (a no-op that documents intent); drop any other requested value
239
- // rather than hard-fail. Restoring allow_adult/dont_allow would require
240
- // a Veo-2 model or an allowlisted Vertex project.
241
- ...(params.personGeneration === "allow_all"
242
- ? { personGeneration: "allow_all" }
265
+ // personGeneration on the Gemini API Veo backend is MODE-dependent, not
266
+ // just model-dependent. Per the Veo API parameter table for Veo 3.1 /
267
+ // 3.1 Fast / 3.1 Lite:
268
+ // Text-to-video & extension "allow_all" ONLY
269
+ // Image-to-video, interpolation, reference images "allow_adult" ONLY
270
+ // The previous comment here claimed allow_all was the only valid value
271
+ // for all of Veo 3.x; that is true only for the text/extension modes.
272
+ // Forwarding allow_all on an image-driven request is a deterministic
273
+ // 400. "dont_allow" is Veo-2-only legacy and is never valid on 3.x.
274
+ //
275
+ // So: forward only the value that is legal for THIS mode, and drop
276
+ // anything else rather than hard-failing (the API default for each mode
277
+ // is that mode's single legal value, so omitting is always safe — it is
278
+ // what the overwhelming majority of our traffic already does).
279
+ ...(params.personGeneration === "allow_all" ||
280
+ params.personGeneration === "allow_adult"
281
+ ? isImageDriven
282
+ ? { personGeneration: "allow_adult" }
283
+ : { personGeneration: "allow_all" }
243
284
  : {}),
244
285
  // negativePrompt support is MODEL-SPECIFIC on the Gemini API: veo-3.1 and
245
286
  // veo-3.1-fast accept it, veo-3.1-lite returns 400 "negativePrompt isn't
@@ -323,8 +364,36 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
323
364
  const keyFailure = (0, googleErrors_1.classifyGoogleKeyHealth)(err);
324
365
  // Not a fail-over-able failure (e.g. content rejection, bad params) →
325
366
  // don't burn other accounts on it; surface as-is.
326
- if (!keyFailure)
367
+ if (!keyFailure) {
368
+ // Log WHAT WE SENT before rethrowing. Veo answers an unsupported
369
+ // parameter combination with a generic 400 ("Unsupported video
370
+ // generation request", "Your use case is currently not supported")
371
+ // that names neither the offending field nor its value — so without
372
+ // this line the Slack alert and the job doc are both dead ends, and
373
+ // the combo can only be guessed at (prod 2026-07-26). Shape only: no
374
+ // prompt text, no image bytes, no signed URLs.
375
+ logger_1.logger.warn("Google Veo: request rejected — submitted shape", {
376
+ modelKey: params.modelKey,
377
+ modelId,
378
+ aspectRatio: params.aspectRatio,
379
+ resolution: params.resolution,
380
+ durationSeconds: params.duration,
381
+ mode: isExtension
382
+ ? "extension"
383
+ : isImageDriven
384
+ ? "image-driven"
385
+ : "text-to-video",
386
+ hasInputImage: !!params.inputImageUrl,
387
+ hasLastFrame: !!params.lastFrameImageUrl,
388
+ referenceImageCount: refCount,
389
+ personGeneration: params.personGeneration ?? null,
390
+ hasNegativePrompt: !!params.negativePrompt,
391
+ enhancePrompt: params.promptOptimizer ?? null,
392
+ promptChars: params.prompt?.length ?? 0,
393
+ error: err instanceof Error ? err.message.slice(0, 500) : String(err),
394
+ });
327
395
  throw err;
396
+ }
328
397
  // Observability: log the RAW provider error once so we can see whether
329
398
  // this was an RPM vs RPD limit and on which key (AI Studio's dashboard
330
399
  // lags real-time, so this is our authoritative signal — Ammar 2026-06-20).
@@ -543,6 +612,11 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
543
612
  const isImagen = modelId.startsWith("imagen-");
544
613
  const isNanoBanana = modelId.startsWith("gemini-") && modelId.includes("image");
545
614
  const allBytes = [];
615
+ // Safety/RAI block reasons harvested from the response when it comes back
616
+ // WITHOUT image bytes. Google does not throw in this case — it returns a
617
+ // 200 with the images filtered out — so the reason is only available here.
618
+ // Mirrors the Veo video path (raiMediaFilteredReasons, above).
619
+ const blockReasons = [];
546
620
  if (isImagen) {
547
621
  const resp = await this.ai.models.generateImages({
548
622
  model: modelId,
@@ -559,13 +633,25 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
559
633
  });
560
634
  for (const gen of resp.generatedImages ?? []) {
561
635
  const b64 = gen.image?.imageBytes;
562
- if (!b64)
636
+ if (!b64) {
637
+ // An entry with no bytes carries the RAI filter reason instead.
638
+ const reason = gen.raiFilteredReason;
639
+ if (reason)
640
+ blockReasons.push(reason);
563
641
  continue;
642
+ }
564
643
  allBytes.push({
565
644
  bytes: Buffer.from(b64, "base64"),
566
645
  mime: gen.image?.mimeType ?? "image/png",
567
646
  });
568
647
  }
648
+ // Prompt-level rejection: Imagen filters the whole request before
649
+ // producing any generatedImages entry, reporting it here instead.
650
+ const promptReason = resp
651
+ .positivePromptSafetyAttributes;
652
+ if (!allBytes.length && promptReason && !blockReasons.length) {
653
+ blockReasons.push("Prompt blocked by Google's safety filters");
654
+ }
569
655
  }
570
656
  else if (isNanoBanana) {
571
657
  // Nano Banana (Gemini image-gen via generateContent) has no structured
@@ -607,11 +693,41 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
607
693
  }
608
694
  }
609
695
  }
696
+ // Gemini reports a safety stop two ways, neither of which throws:
697
+ // promptFeedback.blockReason (whole prompt rejected → candidates empty)
698
+ // and candidate.finishReason (generation halted: IMAGE_SAFETY,
699
+ // PROHIBITED_CONTENT, RECITATION, SPII).
700
+ const promptBlock = resp
701
+ .promptFeedback?.blockReason;
702
+ if (promptBlock)
703
+ blockReasons.push(promptBlock);
704
+ for (const cand of candidates) {
705
+ const finish = cand.finishReason;
706
+ if (finish && finish !== "STOP" && finish !== "MAX_TOKENS") {
707
+ blockReasons.push(finish);
708
+ }
709
+ }
610
710
  }
611
711
  else {
612
712
  throw new Error(`Unsupported Google image modelId: ${modelId}`);
613
713
  }
614
714
  if (!allBytes.length) {
715
+ // A 200 with zero images is almost always a safety/RAI block, not a bug.
716
+ // Previously this threw a bare Error whose message wasn't JSON, so
717
+ // classifyGoogleApiError (which JSON.parses the message) returned null and
718
+ // the image queue logged it at `error` — paging Slack for what is really a
719
+ // user-content outcome. Emit a typed CONTENT_POLICY_VIOLATION when we have
720
+ // a block reason so it's logged at warn, and keep the reason for triage.
721
+ if (blockReasons.length) {
722
+ logger_1.logger.warn("Google image generation blocked by safety filters", {
723
+ modelKey: params.modelKey,
724
+ modelId,
725
+ reasons: blockReasons,
726
+ });
727
+ throw new errors_1.UserFacingError("Your prompt was flagged by Google's safety filters. Please rephrase and try again.", errors_1.USER_FACING_ERROR_CODES.CONTENT_POLICY_VIOLATION);
728
+ }
729
+ // No block reason at all — genuinely unexpected; keep it loud so a real
730
+ // platform/SDK regression isn't muted by the branch above.
615
731
  throw new Error("Google image generation returned no image bytes");
616
732
  }
617
733
  const bucket = (0, firebase_1.getBucket)();
@@ -1 +1 @@
1
- {"version":3,"file":"googleErrors.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/google/googleErrors.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EAGhB,MAAM,0BAA0B,CAAC;AAElC;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,GAAG,GAAG,eAAe,GAAG,IAAI,CAoJvE;AAED;;;+EAG+E;AAC/E,MAAM,WAAW,sBAAsB;IACrC,4DAA4D;IAC5D,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;IACnC,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,UAAU,EAAE,MAAM,CAAC;CACpB;AAcD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,GAAG,GAAG,sBAAsB,GAAG,IAAI,CAuE/E"}
1
+ {"version":3,"file":"googleErrors.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/google/googleErrors.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EAGhB,MAAM,0BAA0B,CAAC;AAElC;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,GAAG,GAAG,eAAe,GAAG,IAAI,CA8JvE;AAED;;;+EAG+E;AAC/E,MAAM,WAAW,sBAAsB;IACrC,4DAA4D;IAC5D,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;IACnC,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,UAAU,EAAE,MAAM,CAAC;CACpB;AAcD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,GAAG,GAAG,sBAAsB,GAAG,IAAI,CAuE/E"}
@@ -49,18 +49,28 @@ function classifyGoogleApiError(err) {
49
49
  if (httpCode === 14 || /high demand/i.test(msg)) {
50
50
  return new errors_1.UserFacingError((0, errors_1.friendlyMessageForCode)(errors_1.USER_FACING_ERROR_CODES.VIDEO_PROVIDER_HIGH_DEMAND), errors_1.USER_FACING_ERROR_CODES.VIDEO_PROVIDER_HIGH_DEMAND);
51
51
  }
52
- // INVALID_ARGUMENT 400 — narrow match: only the specific "use case is
53
- // currently not supported" string, which Veo returns when our request
54
- // structure doesn't match the chosen model variant's capabilities.
52
+ // INVALID_ARGUMENT 400 — Veo's "this request shape isn't supported for this
53
+ // model variant" family. Two known phrasings, both meaning the same thing:
54
+ // - "Your use case is currently not supported" (older)
55
+ // - "Unsupported video generation request. Please check the documentation
56
+ // for supported usage: …/gemini-api/docs/video" (seen in prod on
57
+ // veo-3.1-fast, 2026-07-26 — recurring across many users)
55
58
  // The pre-call guards above (duration=8 for lastFrame/refs) should
56
59
  // prevent the known cases; if we still hit this it's a NEW combo we
57
60
  // haven't profiled — surface as CAPABILITY_MISMATCH so the user gets a
58
61
  // useful message, AND keep the raw provider text in the error so the
59
- // next entry in PROD_FIX_LOG can identify which combo broke. Generic
60
- // 400s (other INVALID_ARGUMENT variants) still surface as `error` so
61
- // a real platform bug isn't muted.
62
+ // next entry in PROD_FIX_LOG can identify which combo broke.
63
+ //
64
+ // Why this matters beyond the message: an unclassified 400 is not a
65
+ // UserFacingError, so the scene loop treats it as a candidate for the
66
+ // transient-retry path and logs it at `error` (which is what pages Slack —
67
+ // see errorPaging.ts). A deterministic 400 can never succeed on retry, so
68
+ // classifying it here both stops the wasted re-submits and moves it to
69
+ // `warn`, since CAPABILITY_MISMATCH is in NON_PAGING_ERROR_CODES.
70
+ // Generic 400s (other INVALID_ARGUMENT variants) still surface as `error`
71
+ // so a real platform bug isn't muted.
62
72
  if ((status === "INVALID_ARGUMENT" || httpCode === 400) &&
63
- /use case is currently not supported/i.test(msg)) {
73
+ /use case is currently not supported|unsupported video generation request/i.test(msg)) {
64
74
  return new errors_1.UserFacingError(msg, errors_1.USER_FACING_ERROR_CODES.CAPABILITY_MISMATCH);
65
75
  }
66
76
  // Imagen + Nano-Banana surface Responsible-AI filter rejections as
@@ -1 +1 @@
1
- {"version":3,"file":"translation.service.d.ts","sourceRoot":"","sources":["../../../src/services/translation/translation.service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGlE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AA+EvD;;;;GAIG;AACH,iBAAe,SAAS,CACtB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,cAAc,EACpB,EAAE,GAAE,cAAkC,EACtC,OAAO,GAAE,IAAI,GAAG,IAAW,GAC1B,OAAO,CAAC,MAAM,CAAC,CAsCjB;AAiCD;;;;;;;;;;;;;;GAcG;AACH,iBAAe,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CA8ChE;AAOD,eAAO,MAAM,kBAAkB;;;CAG9B,CAAC;AAGF,qDAAqD;AACrD,eAAO,MAAM,qBAAqB,kBAAY,CAAC;AAC/C,0DAA0D;AAC1D,eAAO,MAAM,UAAU,uBAAiB,CAAC"}
1
+ {"version":3,"file":"translation.service.d.ts","sourceRoot":"","sources":["../../../src/services/translation/translation.service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGlE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAqGvD;;;;GAIG;AACH,iBAAe,SAAS,CACtB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,cAAc,EACpB,EAAE,GAAE,cAAkC,EACtC,OAAO,GAAE,IAAI,GAAG,IAAW,GAC1B,OAAO,CAAC,MAAM,CAAC,CAsCjB;AAiCD;;;;;;;;;;;;;;GAcG;AACH,iBAAe,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CA8ChE;AAOD,eAAO,MAAM,kBAAkB;;;CAG9B,CAAC;AAGF,qDAAqD;AACrD,eAAO,MAAM,qBAAqB,kBAAY,CAAC;AAC/C,0DAA0D;AAC1D,eAAO,MAAM,UAAU,uBAAiB,CAAC"}
@@ -74,6 +74,13 @@ const PRIMARY_BASE = "https://us-central1-translation-service-3931b.cloudfunctio
74
74
  // 24s user-facing stall was itself part of the problem).
75
75
  const PRIMARY_TIMEOUT_MS = 6000;
76
76
  const PRIMARY_MAX_ATTEMPTS = 2;
77
+ // The LLM fallback had NO timeout, so when the primary CF was also failing the
78
+ // two layers compounded into an unbounded stall — a Vietnamese request was
79
+ // measured at 48s in prod (2026-07-26): ~12s burning both primary attempts,
80
+ // then ~36s waiting on a slow generateText that nothing was bounding. Cap the
81
+ // fallback so the worst case is primary (~12.2s) + fallback (15s) ≈ 27s and the
82
+ // caller gets a definite answer instead of holding the request open.
83
+ const FALLBACK_TIMEOUT_MS = 15000;
77
84
  // gpt-4o-mini: cheap (~$0.15/1M in, $0.60/1M out → fractions of a cent per
78
85
  // prompt), reliable, and independent of the Google video quota. Swap here if a
79
86
  // cheaper effective model becomes available.
@@ -110,13 +117,25 @@ async function callLlmTranslate(text, from, to) {
110
117
  `Preserve meaning, tone, names, numbers, hashtags, emojis and line breaks. ` +
111
118
  `Output ONLY the translated text — no quotes, no language labels, no commentary. ` +
112
119
  `If the text is already in ${target}, return it unchanged.`;
113
- const { text: out } = await service.generateText({
114
- input: [
115
- { role: "system", content: system },
116
- { role: "user", content: text },
117
- ],
118
- modelKey: FALLBACK_MODEL,
119
- options: { temperature: 0 },
120
+ // Bounded: generateText itself takes no timeout, so race it. The loser is
121
+ // abandoned rather than cancelled (there is no AbortSignal on this path), but
122
+ // the caller is unblocked — which is the point.
123
+ let timer;
124
+ const { text: out } = await Promise.race([
125
+ service.generateText({
126
+ input: [
127
+ { role: "system", content: system },
128
+ { role: "user", content: text },
129
+ ],
130
+ modelKey: FALLBACK_MODEL,
131
+ options: { temperature: 0 },
132
+ }),
133
+ new Promise((_, reject) => {
134
+ timer = setTimeout(() => reject(new Error(`LLM fallback timed out after ${FALLBACK_TIMEOUT_MS}ms`)), FALLBACK_TIMEOUT_MS);
135
+ }),
136
+ ]).finally(() => {
137
+ if (timer)
138
+ clearTimeout(timer);
120
139
  });
121
140
  const trimmed = (out ?? "").trim();
122
141
  if (!trimmed || trimmed === "No response") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vidspotai-shared",
3
- "version": "1.0.101-dev.0",
3
+ "version": "1.0.101",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "exports": {