|
297 | 297 |
|
298 | 298 | (defn veo-model [] (or (-> config :veo :model) default-veo-model)) |
299 | 299 |
|
300 | | -(defn- veo-duration [] (long (or (parse-number (-> config :veo :duration)) 4))) |
| 300 | +(defn veo-duration [] (long (or (parse-number (-> config :veo :duration)) 4))) |
301 | 301 |
|
302 | | -(defn- veo-cost-per-second [] |
| 302 | +(defn- veo-cost-per-second-for-model [model] |
303 | 303 | (or (parse-number (-> config :veo :cost-per-second)) |
304 | 304 | (cond |
305 | | - (string/includes? (veo-model) "lite") 0.05 |
306 | | - (string/includes? (veo-model) "fast") 0.15 |
| 305 | + (string/includes? model "lite") 0.05 |
| 306 | + (string/includes? model "fast") 0.15 |
307 | 307 | :else 0.40))) |
308 | 308 |
|
| 309 | +(defn calculate-video-cost |
| 310 | + "Calculate the estimated cost in USD of generating a video of the specified |
| 311 | + duration using the given model." |
| 312 | + [model duration] |
| 313 | + (* duration (veo-cost-per-second-for-model model))) |
| 314 | + |
| 315 | +(defn- veo-cost-per-second [] (veo-cost-per-second-for-model (veo-model))) |
| 316 | + |
| 317 | +(defn- veo-cost-units-for-model [model duration] |
| 318 | + (max 1 (long (Math/ceil (/ (* duration (veo-cost-per-second-for-model model)) |
| 319 | + (cost-per-image)))))) |
| 320 | + |
309 | 321 | (defn- veo-cost-units |
310 | 322 | "Express a Veo clip's dollar cost as a count of image-equivalents so it draws |
311 | 323 | down the shared monthly budget proportionally." |
312 | 324 | [] |
313 | | - (max 1 (long (Math/ceil (/ (* (veo-duration) (veo-cost-per-second)) |
314 | | - (cost-per-image)))))) |
| 325 | + (veo-cost-units-for-model (veo-model) (veo-duration))) |
315 | 326 |
|
316 | 327 | (def ^:private veo-poll-interval-ms 6000) |
317 | 328 | (def ^:private veo-max-polls 50) ; ~5 min ceiling |
318 | 329 |
|
| 330 | +;; Cap connect + read time on every Veo HTTP call so a stalled Google API surfaces |
| 331 | +;; as an error instead of hanging the run forever — an otherwise silent failure |
| 332 | +;; where the user gets neither a video nor an error. |
| 333 | +(def ^:private veo-http-timeouts {:connection-timeout 10000 :socket-timeout 120000}) |
| 334 | + |
319 | 335 | (defn- veo-image-part |
320 | 336 | "Fetch an image URL and return a Veo instance image (base64), or nil. Used for |
321 | 337 | image-to-video — e.g. a mentioned Discord user's avatar becomes the opening frame." |
322 | 338 | [image-url] |
323 | | - (let [resp (client/get image-url {:as :byte-array :throw-exceptions false}) |
| 339 | + (let [resp (client/get image-url (merge veo-http-timeouts |
| 340 | + {:as :byte-array :throw-exceptions false})) |
324 | 341 | mime (first (string/split (get-in resp [:headers "Content-Type"] "image/png") #";"))] |
325 | 342 | (when (<= 200 (:status resp) 299) |
326 | 343 | {:mimeType mime |
|
329 | 346 | (defn- veo-start |
330 | 347 | "Kick off a Veo generation; returns the long-running operation name. |
331 | 348 | When `image` is provided, Veo conditions the video on it (image-to-video)." |
332 | | - [prompt image] |
333 | | - (let [url (format "%s/models/%s:predictLongRunning?key=%s" api-base (veo-model) (:key config)) |
| 349 | + [prompt image model duration] |
| 350 | + (let [model (or model (veo-model)) |
| 351 | + duration (or duration (veo-duration)) |
| 352 | + url (format "%s/models/%s:predictLongRunning?key=%s" api-base model (:key config)) |
334 | 353 | body {:instances [(cond-> {:prompt prompt} image (assoc :image image))] |
335 | | - :parameters {:aspectRatio "16:9" :durationSeconds (veo-duration)}} |
336 | | - resp (client/post url {:content-type :json |
337 | | - :body (json/write-str body) |
338 | | - :as :json |
339 | | - :throw-exceptions false})] |
| 354 | + :parameters {:aspectRatio "16:9" :durationSeconds duration}} |
| 355 | + resp (client/post url (merge veo-http-timeouts |
| 356 | + {:content-type :json |
| 357 | + :body (json/write-str body) |
| 358 | + :as :json |
| 359 | + :throw-exceptions false}))] |
340 | 360 | (if (<= 200 (:status resp) 299) |
341 | 361 | (get-in resp [:body :name]) |
342 | 362 | (let [msg (extract-api-error (:body resp))] |
|
349 | 369 | [op-name] |
350 | 370 | (loop [n 0] |
351 | 371 | (let [body (:body (client/get (format "%s/%s?key=%s" api-base op-name (:key config)) |
352 | | - {:as :json :throw-exceptions false}))] |
| 372 | + (merge veo-http-timeouts |
| 373 | + {:as :json :throw-exceptions false})))] |
353 | 374 | (cond |
354 | 375 | (:error body) (throw (ex-info (str "Veo generation failed: " |
355 | 376 | (get-in body [:error :message])) |
|
362 | 383 | (defn- veo-download |
363 | 384 | "Download the generated mp4 and return it base64-encoded." |
364 | 385 | [uri] |
365 | | - (let [resp (client/get uri {:headers {"x-goog-api-key" (:key config)} |
366 | | - :as :byte-array |
367 | | - :throw-exceptions false})] |
| 386 | + (let [resp (client/get uri (merge veo-http-timeouts |
| 387 | + {:headers {"x-goog-api-key" (:key config)} |
| 388 | + :as :byte-array |
| 389 | + :throw-exceptions false}))] |
368 | 390 | (if (<= 200 (:status resp) 299) |
369 | 391 | (.encodeToString (Base64/getEncoder) ^bytes (:body resp)) |
370 | 392 | (throw (ex-info (str "Veo video download failed: " (:status resp)) |
|
376 | 398 | Optional image-urls condition the video (image-to-video); the first is used. |
377 | 399 | Returns {:data <base64 mp4> :mime-type \"video/mp4\"}." |
378 | 400 | ([prompt] (generate-video prompt nil)) |
379 | | - ([prompt image-urls] |
| 401 | + ([prompt image-urls] (generate-video prompt image-urls nil nil)) |
| 402 | + ([prompt image-urls model duration] |
380 | 403 | (check-budget!) |
381 | | - (let [op (veo-start prompt (some-> (first image-urls) veo-image-part)) |
| 404 | + (let [model (or model (veo-model)) |
| 405 | + duration (or duration (veo-duration)) |
| 406 | + op (veo-start prompt (some-> (first image-urls) veo-image-part) model duration) |
382 | 407 | _ (info "veo: generation started" op) |
383 | 408 | done (veo-poll op) |
384 | 409 | uri (get-in done [:response :generateVideoResponse :generatedSamples 0 :video :uri])] |
385 | 410 | (when (string/blank? uri) |
386 | 411 | (throw (ex-info "No video was generated. Try a different prompt." |
387 | 412 | {:type :no-video-generated :response (:response done)}))) |
388 | 413 | (let [data (veo-download uri)] |
389 | | - (record-image-generated! (veo-cost-units)) |
| 414 | + (record-image-generated! (veo-cost-units-for-model model duration)) |
390 | 415 | {:data data :mime-type "video/mp4"})))) |
0 commit comments