mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 13:02:37 -05:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a1054579 | ||
|
|
692e0ecf75 | ||
|
|
94e1aef78d | ||
|
|
9a4bb3b490 | ||
|
|
299d9b8d23 | ||
|
|
89eff46c6f | ||
|
|
cec3b50ae4 | ||
|
|
3040aaa0d1 | ||
|
|
f47e8b9b14 | ||
|
|
83115e7ef9 | ||
|
|
4fbe9f54e0 | ||
|
|
e850022fe8 | ||
|
|
1a528bba45 | ||
|
|
b625d5ff79 | ||
|
|
d8fd0bfe64 | ||
|
|
3cc02ae7bf | ||
|
|
2c943b61ab | ||
|
|
d92e6fc75c |
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = CURRENT_TIMESTAMP,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "376e36b1357952599866262c7356ba35f15ccdfb338d8ccdbdc06ccd69331fc3"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = NULL,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40a3301e9a3201e74d49b6b339f67291e2ae22d315188401708609ac2d28a68c"
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n job_type as queue_type,\n count(*) as pending_job_count,\n NOW() as present_time\n FROM (\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"pending\", \"attempt_failed\")\n AND job_type IS NOT NULL\n UNION\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"started\")\n AND created_at > (CURDATE() - INTERVAL 15 MINUTE)\n AND job_type IS NOT NULL\n) as generic_inner\nGROUP BY queue_type\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "queue_type",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "pending_job_count",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "present_time",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "73dc054488b2fa3efc923d3a90914a1450b73e99a377562484a0498ccce1676f"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO tts_model_upload_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n creator_user_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n title = ?,\n tts_model_type = ?,\n download_url = ?,\n status = \"pending\"\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "886861e91c7e8af53eb856c949389a172b8d9e079f1c2070d115423907c47c50"
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "user_role_slug",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_banned",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "can_ban_users",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "api_key_token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = CURRENT_TIMESTAMP\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "961f792c24bee6009e3e8a215561db932871a53c48839b8cb80292de00e7b2e1"
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n jobs.token as job_token,\n\n jobs.status,\n jobs.attempt_count,\n jobs.on_success_result_token as maybe_result_token,\n results.public_bucket_wav_audio_path as maybe_public_bucket_wav_audio_path,\n\n jobs.model_token,\n tts.tts_model_type,\n tts.title,\n\n jobs.raw_inference_text,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM tts_inference_jobs as jobs\nJOIN tts_models as tts\n ON tts.token = jobs.model_token\nLEFT OUTER JOIN tts_results as results\n ON jobs.on_success_result_token = results.token\n\nWHERE jobs.token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "attempt_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_result_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_public_bucket_wav_audio_path",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "model_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "tts_model_type",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "raw_inference_text",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 262140
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a2b586dd2120f29dd26281626f7381e292c821f8e826b9bc584b92ffa45035b2"
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.username,\n users.display_name,\n users.email_gravatar_hash as gravatar_hash,\n creator_user_token,\n count(*) as uploaded_count\nFROM tts_models\nJOIN users\nON\n users.token = creator_user_token\nWHERE\n users.is_banned IS FALSE\n AND users.token NOT IN (\"U:DRB1SKEMKHT1J\")\n AND tts_models.user_deleted_at IS NULL\n AND tts_models.mod_deleted_at IS NULL\nGROUP BY creator_user_token\nORDER BY uploaded_count desc\nLIMIT 25;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "gravatar_hash",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "creator_user_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "uploaded_count",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a82fa4eb36528ebf52af2585cdda203476a4ba3645837c792830474fca2eda4f"
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n e.maybe_user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents < 0 THEN -e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(e.amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(e.maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events e\nJOIN users u ON u.token = e.maybe_user_token\nWHERE e.maybe_user_token IS NOT NULL\n AND e.is_production = TRUE\n AND e.payment_occurred_at >= ?\n AND (? IS NULL OR e.payments_namespace = ?)\nGROUP BY e.maybe_user_token, u.username, u.display_name, u.email_gravatar_hash\nORDER BY SUM(e.amount_usd_cents) DESC, e.maybe_user_token ASC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_gravatar_hash!",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "gross_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "refund_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "net_spend_usd_cents!: i64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "payment_count!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "credits_granted!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8"
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "user_role_slug",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_banned",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "can_ban_users",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = NULL\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dbce51eb1c80dded902c9509e50713483c7131f115e8ec10646e99e88110278b"
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE media_files
|
||||
DROP INDEX idx_creator_created_at,
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Composite index for the library / user profile list queries:
|
||||
-- WHERE maybe_creator_user_token = ? ... ORDER BY created_at DESC LIMIT n
|
||||
--
|
||||
-- Without this, the optimizer picks an index-merge intersection against huge
|
||||
-- low-selectivity indexes (e.g. index_user_deleted_at, ~4.75M NULL entries),
|
||||
-- scanning millions of index records per page load.
|
||||
--
|
||||
-- NB: ADD INDEX is online DDL (ALGORITHM=INPLACE, LOCK=NONE) — reads and writes
|
||||
-- continue during the build. Expect a few minutes of elevated I/O on a table
|
||||
-- this size (~8.5M rows).
|
||||
ALTER TABLE media_files
|
||||
ADD INDEX idx_creator_created_at (maybe_creator_user_token, created_at),
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
@@ -364,6 +364,7 @@ CREATE TABLE media_files (
|
||||
KEY index_user_deleted_at (user_deleted_at),
|
||||
KEY index_mod_deleted_at (mod_deleted_at),
|
||||
KEY index_media_files_created_at (created_at),
|
||||
KEY index_media_files_updated_at (updated_at)
|
||||
KEY index_media_files_updated_at (updated_at),
|
||||
KEY idx_creator_created_at (maybe_creator_user_token, created_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod alerts;
|
||||
pub mod debug_logs;
|
||||
pub mod top_spenders;
|
||||
pub mod jobs;
|
||||
pub mod user;
|
||||
pub mod user_referrals;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_LIST_TOP_SPENDERS_PATH: &str =
|
||||
"/v1/moderation/top_spenders/list";
|
||||
|
||||
/// Rolling aggregation window for the top spenders list.
|
||||
#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)]
|
||||
pub enum TopSpendersWindow {
|
||||
#[serde(rename = "24h")]
|
||||
TwentyFourHours,
|
||||
#[serde(rename = "7d")]
|
||||
SevenDays,
|
||||
#[serde(rename = "14d")]
|
||||
FourteenDays,
|
||||
#[serde(rename = "30d")]
|
||||
ThirtyDays,
|
||||
#[serde(rename = "60d")]
|
||||
SixtyDays,
|
||||
#[serde(rename = "90d")]
|
||||
NinetyDays,
|
||||
#[serde(rename = "180d")]
|
||||
OneHundredEightyDays,
|
||||
#[serde(rename = "365d")]
|
||||
ThreeHundredSixtyFiveDays,
|
||||
}
|
||||
|
||||
impl TopSpendersWindow {
|
||||
/// Window length in hours.
|
||||
pub fn as_hours(self) -> i64 {
|
||||
match self {
|
||||
TopSpendersWindow::TwentyFourHours => 24,
|
||||
TopSpendersWindow::SevenDays => 7 * 24,
|
||||
TopSpendersWindow::FourteenDays => 14 * 24,
|
||||
TopSpendersWindow::ThirtyDays => 30 * 24,
|
||||
TopSpendersWindow::SixtyDays => 60 * 24,
|
||||
TopSpendersWindow::NinetyDays => 90 * 24,
|
||||
TopSpendersWindow::OneHundredEightyDays => 180 * 24,
|
||||
TopSpendersWindow::ThreeHundredSixtyFiveDays => 365 * 24,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorListTopSpendersQueryParams {
|
||||
/// Aggregation window: `24h`, `7d`, `14d`, `30d` (default), `60d`, `90d`,
|
||||
/// `180d`, or `365d`.
|
||||
pub window: Option<TopSpendersWindow>,
|
||||
/// Row offset for pagination (page size is fixed at 100). Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorListTopSpendersResponse {
|
||||
pub success: bool,
|
||||
/// The window the results were aggregated over (echoed back, post-default).
|
||||
pub window: TopSpendersWindow,
|
||||
pub spenders: Vec<TopSpenderEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TopSpenderEntry {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_gravatar_hash: String,
|
||||
/// Sum of positive amounts in the window.
|
||||
pub gross_spend_usd_cents: u64,
|
||||
/// Refund/chargeback magnitude in the window (positive number).
|
||||
pub refund_usd_cents: u64,
|
||||
/// Gross minus refunds. Can be negative if refunds outweigh purchases.
|
||||
pub net_spend_usd_cents: i64,
|
||||
/// Number of positive money events in the window.
|
||||
pub payment_count: u64,
|
||||
/// Credits granted in the window.
|
||||
pub credits_granted: u64,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod list;
|
||||
@@ -98,6 +98,15 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "veo_3p1_fast")]
|
||||
Veo3p1Fast,
|
||||
|
||||
#[serde(rename = "veo_3p1_lite")]
|
||||
Veo3p1Lite,
|
||||
|
||||
#[serde(rename = "vidu_q3")]
|
||||
ViduQ3,
|
||||
|
||||
#[serde(rename = "vidu_q3_turbo")]
|
||||
ViduQ3Turbo,
|
||||
|
||||
#[serde(rename = "preview_model")]
|
||||
PreviewModel,
|
||||
|
||||
@@ -123,4 +132,13 @@ mod tests {
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn veo_3p1_lite_and_vidu_variants_serialize() {
|
||||
// NB: These strings must match `CommonVideoModel` — the two enums convert
|
||||
// via serde string round-trip in the omni handlers.
|
||||
assert_serde_round_trip(RouterVideoModel::Veo3p1Lite, "veo_3p1_lite");
|
||||
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
|
||||
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
|
||||
}
|
||||
}
|
||||
|
||||
+35
-36
@@ -1,6 +1,5 @@
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
|
||||
EnqueueFlux2LoraAngleImageSize, EnqueueFlux2LoraAngleNumImages,
|
||||
EnqueueFlux2LoraEditImageAngleRequest,
|
||||
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
|
||||
Flux2LoraAngleImageSize, Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
@@ -27,7 +26,7 @@ pub fn build_fal_flux_2_lora_angles(
|
||||
let num_images = plan_num_images(builder.image_batch_count, strategy)?;
|
||||
let image_size = plan_image_size(builder.aspect_ratio, strategy)?;
|
||||
|
||||
let request = EnqueueFlux2LoraEditImageAngleRequest {
|
||||
let request = Flux2LoraEditImageAngleRequest {
|
||||
image_urls,
|
||||
horizontal_angle: builder.horizontal_angle,
|
||||
vertical_angle: builder.vertical_angle.map(|v| v.clamp(0.0, 60.0)),
|
||||
@@ -57,8 +56,8 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
|
||||
fn plan_num_images(
|
||||
image_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<EnqueueFlux2LoraAngleNumImages, ArtcraftRouterError> {
|
||||
use EnqueueFlux2LoraAngleNumImages as N;
|
||||
) -> Result<Flux2LoraAngleNumImages, ArtcraftRouterError> {
|
||||
use Flux2LoraAngleNumImages as N;
|
||||
let count = image_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
@@ -81,8 +80,8 @@ fn plan_num_images(
|
||||
fn plan_image_size(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<EnqueueFlux2LoraAngleImageSize>, ArtcraftRouterError> {
|
||||
use EnqueueFlux2LoraAngleImageSize as S;
|
||||
) -> Result<Option<Flux2LoraAngleImageSize>, ArtcraftRouterError> {
|
||||
use Flux2LoraAngleImageSize as S;
|
||||
match aspect_ratio {
|
||||
None => Ok(None),
|
||||
|
||||
@@ -118,34 +117,6 @@ mod tests {
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
|
||||
fn base() -> GenerateImageRequestBuilder {
|
||||
GenerateImageRequestBuilder {
|
||||
model: RouterImageModel::Flux2LoraAngles,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: None,
|
||||
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(-15.0),
|
||||
zoom: Some(2.0),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> EnqueueFlux2LoraEditImageAngleRequest {
|
||||
let ImageGenerationDraftOrRequest::Request(
|
||||
ImageGenerationRequest::FalFlux2LoraAngles(state)
|
||||
) = result.expect("build should succeed") else {
|
||||
panic!("expected FalFlux2LoraAngles variant")
|
||||
};
|
||||
state.request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_camera_params() {
|
||||
let builder = GenerateImageRequestBuilder {
|
||||
@@ -207,4 +178,32 @@ mod tests {
|
||||
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
|
||||
));
|
||||
}
|
||||
|
||||
fn base() -> GenerateImageRequestBuilder {
|
||||
GenerateImageRequestBuilder {
|
||||
model: RouterImageModel::Flux2LoraAngles,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: None,
|
||||
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(-15.0),
|
||||
zoom: Some(2.0),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> Flux2LoraEditImageAngleRequest {
|
||||
let ImageGenerationDraftOrRequest::Request(
|
||||
ImageGenerationRequest::FalFlux2LoraAngles(state)
|
||||
) = result.expect("build should succeed") else {
|
||||
panic!("expected FalFlux2LoraAngles variant")
|
||||
};
|
||||
state.request
|
||||
}
|
||||
}
|
||||
|
||||
+24
-24
@@ -32,13 +32,33 @@ impl FalFlux2LoraAnglesCostState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
|
||||
EnqueueFlux2LoraAngleNumImages, EnqueueFlux2LoraEditImageAngleRequest,
|
||||
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
|
||||
Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest,
|
||||
};
|
||||
|
||||
fn cost_for(n: EnqueueFlux2LoraAngleNumImages) -> ImageGenerationCostEstimate {
|
||||
#[test]
|
||||
fn one_image_is_2_cents() {
|
||||
assert_eq!(cost_for(Flux2LoraAngleNumImages::One).cost_in_usd_cents, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_images_is_4_cents() {
|
||||
assert_eq!(cost_for(Flux2LoraAngleNumImages::Two).cost_in_usd_cents, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_images_is_6_cents() {
|
||||
assert_eq!(cost_for(Flux2LoraAngleNumImages::Three).cost_in_usd_cents, Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_images_is_8_cents() {
|
||||
assert_eq!(cost_for(Flux2LoraAngleNumImages::Four).cost_in_usd_cents, Some(8));
|
||||
}
|
||||
|
||||
fn cost_for(n: Flux2LoraAngleNumImages) -> ImageGenerationCostEstimate {
|
||||
let state = FalFlux2LoraAnglesCostState::from_request(&FalFlux2LoraAnglesRequestState {
|
||||
request: EnqueueFlux2LoraEditImageAngleRequest {
|
||||
request: Flux2LoraEditImageAngleRequest {
|
||||
image_urls: vec!["https://example.com/x.jpg".to_string()],
|
||||
horizontal_angle: None,
|
||||
vertical_angle: None,
|
||||
@@ -52,24 +72,4 @@ mod tests {
|
||||
});
|
||||
state.estimate_cost()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_image_is_2_cents() {
|
||||
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::One).cost_in_usd_cents, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_images_is_4_cents() {
|
||||
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Two).cost_in_usd_cents, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_images_is_6_cents() {
|
||||
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Three).cost_in_usd_cents, Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_images_is_8_cents() {
|
||||
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Four).cost_in_usd_cents, Some(8));
|
||||
}
|
||||
}
|
||||
|
||||
+69
-46
@@ -1,55 +1,100 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
|
||||
enqueue_flux_2_lora_edit_image_angle_webhook, EnqueueFlux2LoraEditImageAngleArgs,
|
||||
EnqueueFlux2LoraEditImageAngleRequest,
|
||||
};
|
||||
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::Flux2LoraEditImageAngleRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_image::generate_image_response::{
|
||||
FalImageResponsePayload, GenerateImageResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalFlux2LoraAnglesRequestState {
|
||||
pub request: EnqueueFlux2LoraEditImageAngleRequest,
|
||||
pub request: Flux2LoraEditImageAngleRequest,
|
||||
}
|
||||
|
||||
impl FalFlux2LoraAnglesRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
|
||||
let args = EnqueueFlux2LoraEditImageAngleArgs {
|
||||
request: self.request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
let webhook_response = enqueue_flux_2_lora_edit_image_angle_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
|
||||
let payload = send_fal_request(&self.request, client).await?;
|
||||
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request
|
||||
.send_webhook_request(&client.api_key, webhook_url)
|
||||
.await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request
|
||||
.send_queue_request(&client.api_key)
|
||||
.await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
|
||||
EnqueueFlux2LoraAngleImageSize, EnqueueFlux2LoraAngleNumImages,
|
||||
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
|
||||
Flux2LoraAngleImageSize, Flux2LoraAngleNumImages,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_via_webhook() {
|
||||
let client = client_with_webhook();
|
||||
let state = FalFlux2LoraAnglesRequestState {
|
||||
request: Flux2LoraEditImageAngleRequest {
|
||||
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(15.0),
|
||||
zoom: Some(2.0),
|
||||
num_images: Some(Flux2LoraAngleNumImages::One),
|
||||
image_size: Some(Flux2LoraAngleImageSize::SquareHd),
|
||||
lora_scale: None,
|
||||
guidance_scale: None,
|
||||
num_inference_steps: None,
|
||||
},
|
||||
};
|
||||
let response = state.send(&client).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
@@ -63,26 +108,4 @@ mod tests {
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_via_webhook() {
|
||||
let client = client_with_webhook();
|
||||
let state = FalFlux2LoraAnglesRequestState {
|
||||
request: EnqueueFlux2LoraEditImageAngleRequest {
|
||||
image_urls: vec!["https://example.com/test.jpg".to_string()],
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(-15.0),
|
||||
zoom: Some(2.0),
|
||||
num_images: Some(EnqueueFlux2LoraAngleNumImages::One),
|
||||
image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd),
|
||||
lora_scale: None,
|
||||
guidance_scale: None,
|
||||
num_inference_steps: None,
|
||||
},
|
||||
};
|
||||
let response = state.send(&client).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
+37
-38
@@ -1,6 +1,5 @@
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
|
||||
EnqueueQwenEdit2511AngleImageSize, EnqueueQwenEdit2511AngleNumImages,
|
||||
EnqueueQwenEdit2511EditImageAngleRequest,
|
||||
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
|
||||
QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
@@ -27,7 +26,7 @@ pub fn build_fal_qwen_edit_2511_angles(
|
||||
let num_images = plan_num_images(builder.image_batch_count, strategy)?;
|
||||
let image_size = plan_image_size(builder.aspect_ratio, strategy)?;
|
||||
|
||||
let request = EnqueueQwenEdit2511EditImageAngleRequest {
|
||||
let request = QwenEdit2511EditImageAngleRequest {
|
||||
image_urls,
|
||||
horizontal_angle: builder.horizontal_angle,
|
||||
vertical_angle: builder.vertical_angle,
|
||||
@@ -58,8 +57,8 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
|
||||
fn plan_num_images(
|
||||
image_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<EnqueueQwenEdit2511AngleNumImages, ArtcraftRouterError> {
|
||||
use EnqueueQwenEdit2511AngleNumImages as N;
|
||||
) -> Result<QwenEdit2511AngleNumImages, ArtcraftRouterError> {
|
||||
use QwenEdit2511AngleNumImages as N;
|
||||
let count = image_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
@@ -82,8 +81,8 @@ fn plan_num_images(
|
||||
fn plan_image_size(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<EnqueueQwenEdit2511AngleImageSize>, ArtcraftRouterError> {
|
||||
use EnqueueQwenEdit2511AngleImageSize as S;
|
||||
) -> Result<Option<QwenEdit2511AngleImageSize>, ArtcraftRouterError> {
|
||||
use QwenEdit2511AngleImageSize as S;
|
||||
match aspect_ratio {
|
||||
None => Ok(None),
|
||||
|
||||
@@ -119,34 +118,6 @@ mod tests {
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
|
||||
fn base() -> GenerateImageRequestBuilder {
|
||||
GenerateImageRequestBuilder {
|
||||
model: RouterImageModel::QwenEdit2511Angles,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(-15.0),
|
||||
zoom: Some(2.0),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> EnqueueQwenEdit2511EditImageAngleRequest {
|
||||
let ImageGenerationDraftOrRequest::Request(
|
||||
ImageGenerationRequest::FalQwenEdit2511Angles(state)
|
||||
) = result.expect("build should succeed") else {
|
||||
panic!("expected FalQwenEdit2511Angles variant")
|
||||
};
|
||||
state.request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_camera_params() {
|
||||
let req = unwrap_request(build_fal_qwen_edit_2511_angles(base()));
|
||||
@@ -177,7 +148,7 @@ mod tests {
|
||||
#[test]
|
||||
fn default_num_images_is_one() {
|
||||
let req = unwrap_request(build_fal_qwen_edit_2511_angles(base()));
|
||||
assert!(matches!(req.num_images, Some(EnqueueQwenEdit2511AngleNumImages::One)));
|
||||
assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::One)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,6 +168,34 @@ mod tests {
|
||||
..base()
|
||||
};
|
||||
let req = unwrap_request(build_fal_qwen_edit_2511_angles(builder));
|
||||
assert!(matches!(req.num_images, Some(EnqueueQwenEdit2511AngleNumImages::Four)));
|
||||
assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::Four)));
|
||||
}
|
||||
|
||||
fn base() -> GenerateImageRequestBuilder {
|
||||
GenerateImageRequestBuilder {
|
||||
model: RouterImageModel::QwenEdit2511Angles,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(-15.0),
|
||||
zoom: Some(2.0),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> QwenEdit2511EditImageAngleRequest {
|
||||
let ImageGenerationDraftOrRequest::Request(
|
||||
ImageGenerationRequest::FalQwenEdit2511Angles(state)
|
||||
) = result.expect("build should succeed") else {
|
||||
panic!("expected FalQwenEdit2511Angles variant")
|
||||
};
|
||||
state.request
|
||||
}
|
||||
}
|
||||
|
||||
+30
-30
@@ -32,13 +32,39 @@ impl FalQwenEdit2511AnglesCostState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
|
||||
EnqueueQwenEdit2511AngleNumImages, EnqueueQwenEdit2511EditImageAngleRequest,
|
||||
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
|
||||
QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest,
|
||||
};
|
||||
|
||||
fn cost_for(n: EnqueueQwenEdit2511AngleNumImages) -> ImageGenerationCostEstimate {
|
||||
#[test]
|
||||
fn one_image_is_4_cents() {
|
||||
assert_eq!(cost_for(QwenEdit2511AngleNumImages::One).cost_in_usd_cents, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_images_is_8_cents() {
|
||||
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Two).cost_in_usd_cents, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_images_is_12_cents() {
|
||||
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Three).cost_in_usd_cents, Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_images_is_16_cents() {
|
||||
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Four).cost_in_usd_cents, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_equal_cents() {
|
||||
let cost = cost_for(QwenEdit2511AngleNumImages::Two);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
|
||||
fn cost_for(n: QwenEdit2511AngleNumImages) -> ImageGenerationCostEstimate {
|
||||
let state = FalQwenEdit2511AnglesCostState::from_request(&FalQwenEdit2511AnglesRequestState {
|
||||
request: EnqueueQwenEdit2511EditImageAngleRequest {
|
||||
request: QwenEdit2511EditImageAngleRequest {
|
||||
image_urls: vec!["https://example.com/x.jpg".to_string()],
|
||||
horizontal_angle: None,
|
||||
vertical_angle: None,
|
||||
@@ -53,30 +79,4 @@ mod tests {
|
||||
});
|
||||
state.estimate_cost()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_image_is_4_cents() {
|
||||
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::One).cost_in_usd_cents, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_images_is_8_cents() {
|
||||
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Two).cost_in_usd_cents, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_images_is_12_cents() {
|
||||
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Three).cost_in_usd_cents, Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_images_is_16_cents() {
|
||||
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Four).cost_in_usd_cents, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_equal_cents() {
|
||||
let cost = cost_for(EnqueueQwenEdit2511AngleNumImages::Two);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
|
||||
+70
-47
@@ -1,55 +1,101 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
|
||||
enqueue_qwen_edit_2511_edit_image_angle_webhook, EnqueueQwenEdit2511EditImageAngleArgs,
|
||||
EnqueueQwenEdit2511EditImageAngleRequest,
|
||||
};
|
||||
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::QwenEdit2511EditImageAngleRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_image::generate_image_response::{
|
||||
FalImageResponsePayload, GenerateImageResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalQwenEdit2511AnglesRequestState {
|
||||
pub request: EnqueueQwenEdit2511EditImageAngleRequest,
|
||||
pub request: QwenEdit2511EditImageAngleRequest,
|
||||
}
|
||||
|
||||
impl FalQwenEdit2511AnglesRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
|
||||
let args = EnqueueQwenEdit2511EditImageAngleArgs {
|
||||
request: self.request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
let webhook_response = enqueue_qwen_edit_2511_edit_image_angle_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
|
||||
let payload = send_fal_request(&self.request, client).await?;
|
||||
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request
|
||||
.send_webhook_request(&client.api_key, webhook_url)
|
||||
.await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request
|
||||
.send_queue_request(&client.api_key)
|
||||
.await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
|
||||
EnqueueQwenEdit2511AngleImageSize, EnqueueQwenEdit2511AngleNumImages,
|
||||
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
|
||||
QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_via_webhook() {
|
||||
let client = client_with_webhook();
|
||||
let state = FalQwenEdit2511AnglesRequestState {
|
||||
request: QwenEdit2511EditImageAngleRequest {
|
||||
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(-15.0),
|
||||
zoom: Some(2.0),
|
||||
additional_prompt: Some("cinematic".to_string()),
|
||||
num_images: Some(QwenEdit2511AngleNumImages::One),
|
||||
image_size: Some(QwenEdit2511AngleImageSize::SquareHd),
|
||||
lora_scale: None,
|
||||
guidance_scale: None,
|
||||
num_inference_steps: None,
|
||||
},
|
||||
};
|
||||
let response = state.send(&client).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
@@ -63,27 +109,4 @@ mod tests {
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_via_webhook() {
|
||||
let client = client_with_webhook();
|
||||
let state = FalQwenEdit2511AnglesRequestState {
|
||||
request: EnqueueQwenEdit2511EditImageAngleRequest {
|
||||
image_urls: vec!["https://example.com/test.jpg".to_string()],
|
||||
horizontal_angle: Some(45.0),
|
||||
vertical_angle: Some(-15.0),
|
||||
zoom: Some(2.0),
|
||||
additional_prompt: Some("cinematic".to_string()),
|
||||
num_images: Some(EnqueueQwenEdit2511AngleNumImages::One),
|
||||
image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd),
|
||||
lora_scale: None,
|
||||
guidance_scale: None,
|
||||
num_inference_steps: None,
|
||||
},
|
||||
};
|
||||
let response = state.send(&client).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -42,6 +42,9 @@ use crate::generate::generate_video::providers::artcraft::veo_3::build::build_ar
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::build_artcraft_veo_3_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
|
||||
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
|
||||
@@ -64,6 +67,9 @@ use crate::generate::generate_video::providers::fal::veo_3::build::build_fal_veo
|
||||
use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fal_veo_3_fast;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
@@ -194,6 +200,9 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Veo3Fast) => build_artcraft_veo_3_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1) => build_artcraft_veo_3p1(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1Fast) => build_artcraft_veo_3p1_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1Lite) => build_artcraft_veo_3p1_lite(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
|
||||
// Fal
|
||||
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
|
||||
@@ -211,6 +220,9 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Fal, RouterVideoModel::Veo3Fast) => build_fal_veo_3_fast(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Veo3p1) => build_fal_veo_3p1(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Veo3p1Fast) => build_fal_veo_3p1_fast(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
|
||||
// GmiCloud
|
||||
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
|
||||
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
|
||||
|
||||
+3
@@ -36,4 +36,7 @@ pub mod veo_3;
|
||||
pub mod veo_3_fast;
|
||||
pub mod veo_3p1;
|
||||
pub mod veo_3p1_fast;
|
||||
pub mod veo_3p1_lite;
|
||||
pub mod vidu_q3;
|
||||
pub mod vidu_q3_turbo;
|
||||
|
||||
|
||||
+7
-7
@@ -15,9 +15,9 @@ impl ArtcraftVeo2CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client veo_2: 5s = $2.50, +$0.50/s above 5s.
|
||||
// 5s = $2.75, +$0.55/s above 5s.
|
||||
let extra = self.duration_seconds.saturating_sub(5);
|
||||
let cost_in_usd_cents = 250 + extra * 50;
|
||||
let cost_in_usd_cents = 275 + extra * 55;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -49,17 +49,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_5s_priced_at_250() { assert_eq!(cost_cents(None), 250); }
|
||||
fn default_is_5s_priced_at_275() { assert_eq!(cost_cents(None), 275); }
|
||||
|
||||
#[test]
|
||||
fn five_seconds_is_250() { assert_eq!(cost_cents(Some(5)), 250); }
|
||||
fn five_seconds_is_275() { assert_eq!(cost_cents(Some(5)), 275); }
|
||||
|
||||
#[test]
|
||||
fn six_seconds_is_300() { assert_eq!(cost_cents(Some(6)), 300); }
|
||||
fn six_seconds_is_330() { assert_eq!(cost_cents(Some(6)), 330); }
|
||||
|
||||
#[test]
|
||||
fn seven_seconds_is_350() { assert_eq!(cost_cents(Some(7)), 350); }
|
||||
fn seven_seconds_is_385() { assert_eq!(cost_cents(Some(7)), 385); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_is_400() { assert_eq!(cost_cents(Some(8)), 400); }
|
||||
fn eight_seconds_is_440() { assert_eq!(cost_cents(Some(8)), 440); }
|
||||
}
|
||||
|
||||
+7
-7
@@ -17,8 +17,8 @@ impl ArtcraftVeo3CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client veo_3: $0.20/sec audio off, $0.40/sec audio on.
|
||||
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
|
||||
// $0.24/sec audio off, $0.48/sec audio on.
|
||||
let per_second_cents: u64 = if self.generate_audio { 48 } else { 24 };
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
@@ -61,19 +61,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false)), 80); }
|
||||
fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_6s_is_120() { assert_eq!(cost_cents(Some(6), Some(false)), 120); }
|
||||
fn audio_off_6s_is_144() { assert_eq!(cost_cents(Some(6), Some(false)), 144); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_8s_is_160() { assert_eq!(cost_cents(Some(8), Some(false)), 160); }
|
||||
fn audio_off_8s_is_192() { assert_eq!(cost_cents(Some(8), Some(false)), 192); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true)), 160); }
|
||||
fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true)), 320); }
|
||||
fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_8s() {
|
||||
|
||||
+9
-11
@@ -15,10 +15,8 @@ impl ArtcraftVeo3FastCostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client veo_3_fast: $0.10/sec audio off, $0.15/sec audio on.
|
||||
// Legacy handler always bills 8s.
|
||||
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
|
||||
let cost_in_usd_cents = per_second_cents * 8;
|
||||
// Flat per-video price: 88¢ audio off, 132¢ audio on.
|
||||
let cost_in_usd_cents: u64 = if self.generate_audio { 132 } else { 88 };
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -51,22 +49,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_is_80() { assert_eq!(cost_cents(Some(8), Some(false)), 80); }
|
||||
fn audio_off_is_88() { assert_eq!(cost_cents(Some(8), Some(false)), 88); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
|
||||
fn audio_on_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
|
||||
|
||||
#[test]
|
||||
fn audio_default_is_off() {
|
||||
assert_eq!(cost_cents(Some(8), None), 80);
|
||||
assert_eq!(cost_cents(Some(8), None), 88);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_does_not_affect_cost() {
|
||||
// v1 always bills 8s regardless of duration_seconds.
|
||||
assert_eq!(cost_cents(Some(4), Some(false)), 80);
|
||||
assert_eq!(cost_cents(Some(6), Some(false)), 80);
|
||||
assert_eq!(cost_cents(Some(8), Some(false)), 80);
|
||||
assert_eq!(cost_cents(None, Some(false)), 80);
|
||||
assert_eq!(cost_cents(Some(4), Some(false)), 88);
|
||||
assert_eq!(cost_cents(Some(6), Some(false)), 88);
|
||||
assert_eq!(cost_cents(Some(8), Some(false)), 88);
|
||||
assert_eq!(cost_cents(None, Some(false)), 88);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ pub fn build_artcraft_veo_3p1(mut builder: GenerateVideoRequestBuilder) -> Resul
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Veo3p1,
|
||||
SupportedResolutions::Full,
|
||||
SupportedResolutions::FullWith4k,
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
request.generate_audio = generate_audio;
|
||||
|
||||
+46
-8
@@ -1,3 +1,5 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState;
|
||||
|
||||
@@ -5,6 +7,7 @@ use crate::generate::generate_video::providers::artcraft::veo_3p1::request::Artc
|
||||
pub struct ArtcraftVeo3p1CostState {
|
||||
pub duration_seconds: u64,
|
||||
pub generate_audio: bool,
|
||||
pub is_4k: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftVeo3p1CostState {
|
||||
@@ -14,12 +17,19 @@ impl ArtcraftVeo3p1CostState {
|
||||
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
|
||||
// v1 legacy Veo 3.1 handler defaults generate_audio to true.
|
||||
generate_audio: request.request.generate_audio.unwrap_or(true),
|
||||
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client veo_3p1: $0.20/sec audio off, $0.40/sec audio on.
|
||||
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
|
||||
// $0.24/sec audio off, $0.48/sec audio on; 4K renders bill a premium:
|
||||
// $0.48/sec audio off, $0.72/sec audio on.
|
||||
let per_second_cents: u64 = match (self.is_4k, self.generate_audio) {
|
||||
(false, false) => 24,
|
||||
(false, true) => 48,
|
||||
(true, false) => 48,
|
||||
(true, true) => 72,
|
||||
};
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
@@ -36,12 +46,22 @@ impl ArtcraftVeo3p1CostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
|
||||
cost_cents_at(duration_seconds, generate_audio, None)
|
||||
}
|
||||
|
||||
fn cost_cents_at(
|
||||
duration_seconds: Option<u16>,
|
||||
generate_audio: Option<bool>,
|
||||
resolution: Option<RouterResolution>,
|
||||
) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
resolution,
|
||||
model: RouterVideoModel::Veo3p1,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("test".to_string()),
|
||||
@@ -53,24 +73,42 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true)), 160); }
|
||||
fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_6s_is_240() { assert_eq!(cost_cents(Some(6), Some(true)), 240); }
|
||||
fn audio_on_6s_is_288() { assert_eq!(cost_cents(Some(6), Some(true)), 288); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true)), 320); }
|
||||
fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false)), 80); }
|
||||
fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_6s() {
|
||||
assert_eq!(cost_cents(None, Some(true)), 240);
|
||||
assert_eq!(cost_cents(None, Some(true)), 288);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_default_is_on() {
|
||||
assert_eq!(cost_cents(Some(6), None), 240);
|
||||
assert_eq!(cost_cents(Some(6), None), 288);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_audio_off_4s_is_192() {
|
||||
assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_audio_on_4s_is_288() {
|
||||
assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 288);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_four_k_resolutions_share_the_base_rate() {
|
||||
assert_eq!(
|
||||
cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)),
|
||||
cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ pub fn build_artcraft_veo_3p1_fast(mut builder: GenerateVideoRequestBuilder) ->
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Veo3p1Fast,
|
||||
SupportedResolutions::Full,
|
||||
SupportedResolutions::FullWith4k,
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
request.generate_audio = generate_audio;
|
||||
|
||||
+47
-9
@@ -1,3 +1,5 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState;
|
||||
|
||||
@@ -5,6 +7,7 @@ use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request:
|
||||
pub struct ArtcraftVeo3p1FastCostState {
|
||||
pub duration_seconds: u64,
|
||||
pub generate_audio: bool,
|
||||
pub is_4k: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftVeo3p1FastCostState {
|
||||
@@ -14,13 +17,20 @@ impl ArtcraftVeo3p1FastCostState {
|
||||
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
|
||||
// v1 legacy Veo 3.1 Fast handler defaults generate_audio to true.
|
||||
generate_audio: request.request.generate_audio.unwrap_or(true),
|
||||
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client veo_3p1_fast: $0.10/sec audio off, $0.15/sec audio on.
|
||||
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
// Per-second rates in hundredths of a US cent; 4K renders bill a premium.
|
||||
let rate_hundredth_cents: u64 = match (self.is_4k, self.generate_audio) {
|
||||
(false, false) => 1_100,
|
||||
(false, true) => 1_650,
|
||||
(true, false) => 3_300,
|
||||
(true, true) => 3_850,
|
||||
};
|
||||
// Round up to the next whole cent.
|
||||
let cost_in_usd_cents = (rate_hundredth_cents * self.duration_seconds).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -36,12 +46,22 @@ impl ArtcraftVeo3p1FastCostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
|
||||
cost_cents_at(duration_seconds, generate_audio, None)
|
||||
}
|
||||
|
||||
fn cost_cents_at(
|
||||
duration_seconds: Option<u16>,
|
||||
generate_audio: Option<bool>,
|
||||
resolution: Option<RouterResolution>,
|
||||
) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
resolution,
|
||||
model: RouterVideoModel::Veo3p1Fast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("test".to_string()),
|
||||
@@ -53,24 +73,42 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_4s_is_60() { assert_eq!(cost_cents(Some(4), Some(true)), 60); }
|
||||
fn audio_on_4s_is_66() { assert_eq!(cost_cents(Some(4), Some(true)), 66); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true)), 90); }
|
||||
fn audio_on_6s_is_99() { assert_eq!(cost_cents(Some(6), Some(true)), 99); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
|
||||
fn audio_on_8s_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_4s_is_40() { assert_eq!(cost_cents(Some(4), Some(false)), 40); }
|
||||
fn audio_off_4s_is_44() { assert_eq!(cost_cents(Some(4), Some(false)), 44); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_6s() {
|
||||
assert_eq!(cost_cents(None, Some(true)), 90);
|
||||
assert_eq!(cost_cents(None, Some(true)), 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_default_is_on() {
|
||||
assert_eq!(cost_cents(Some(6), None), 90);
|
||||
assert_eq!(cost_cents(Some(6), None), 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_audio_off_4s_is_132() {
|
||||
assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 132);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_audio_on_4s_is_154() {
|
||||
assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 154);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_four_k_resolutions_share_the_base_rate() {
|
||||
assert_eq!(
|
||||
cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)),
|
||||
cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_veo_3p1_lite(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_artcraft_veo_3p1_lite_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1Lite(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_artcraft_veo_3p1_lite_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftVeo3p1LiteRequestState, ArtcraftRouterError> {
|
||||
// Veo 3.1 Lite supports `generate_audio` — preserve it so cost can read it back.
|
||||
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
|
||||
let generate_audio = builder.generate_audio;
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Veo3p1Lite,
|
||||
SupportedResolutions::NoFourEightyP,
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
request.generate_audio = generate_audio;
|
||||
Ok(ArtcraftVeo3p1LiteRequestState { request })
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
|
||||
|
||||
/// Per-second rates in hundredths of a US cent.
|
||||
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 315;
|
||||
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 525;
|
||||
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 525;
|
||||
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 840;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftVeo3p1LiteCostState {
|
||||
pub duration_seconds: u64,
|
||||
pub is_1080p: bool,
|
||||
pub generate_audio: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftVeo3p1LiteCostState {
|
||||
pub fn from_request(request: &ArtcraftVeo3p1LiteRequestState) -> Self {
|
||||
Self {
|
||||
// Veo 3.1 Lite defaults None → 8s.
|
||||
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(8),
|
||||
is_1080p: is_1080p(request.request.resolution),
|
||||
// Audio is on when unspecified.
|
||||
generate_audio: request.request.generate_audio.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let rate = match (self.is_1080p, self.generate_audio) {
|
||||
(false, false) => SEVEN_TWENTY_P_AUDIO_OFF_RATE,
|
||||
(false, true) => SEVEN_TWENTY_P_AUDIO_ON_RATE,
|
||||
(true, false) => TEN_EIGHTY_P_AUDIO_OFF_RATE,
|
||||
(true, true) => TEN_EIGHTY_P_AUDIO_ON_RATE,
|
||||
};
|
||||
// Round up to the next whole cent.
|
||||
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Veo 3.1 Lite renders 720p (the default) or 1080p.
|
||||
fn is_1080p(resolution: Option<CommonResolutionEnum>) -> bool {
|
||||
matches!(
|
||||
resolution,
|
||||
Some(CommonResolutionEnum::TenEightyP)
|
||||
| Some(CommonResolutionEnum::TwoK)
|
||||
| Some(CommonResolutionEnum::ThreeK)
|
||||
| Some(CommonResolutionEnum::FourK)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite_state;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::cost::ArtcraftVeo3p1LiteCostState;
|
||||
|
||||
fn cost_cents(
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
generate_audio: Option<bool>,
|
||||
) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Lite,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
let state = build_artcraft_veo_3p1_lite_state(b).unwrap();
|
||||
ArtcraftVeo3p1LiteCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_8s_720p_audio_on_is_42() { assert_eq!(cost_cents(None, None, None), 42); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_8s_720p_is_26() {
|
||||
// 315 * 8 = 2520 hundredth-cents → 26 cents.
|
||||
assert_eq!(cost_cents(Some(8), None, Some(false)), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_8s_1080p_is_68() {
|
||||
// 840 * 8 = 6720 hundredth-cents → 68 cents.
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 68);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_8s_1080p_is_42() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_4s_720p_is_21() {
|
||||
assert_eq!(cost_cents(Some(4), None, Some(true)), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_default_is_on() {
|
||||
assert_eq!(cost_cents(Some(8), None, None), cost_cents(Some(8), None, Some(true)));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftVeo3p1LiteRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftVeo3p1LiteRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A lighthouse beam sweeping across a foggy bay at night.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn keyframe_start_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog turns its head toward the camera.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Lite,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(8),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_vidu_q3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_artcraft_vidu_q3_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_artcraft_vidu_q3_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftViduQ3RequestState, ArtcraftRouterError> {
|
||||
// Vidu Q3 supports `audio` — preserve it so cost can read it back.
|
||||
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
|
||||
let generate_audio = builder.generate_audio;
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::ViduQ3,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
request.generate_audio = generate_audio;
|
||||
Ok(ArtcraftViduQ3RequestState { request })
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState;
|
||||
|
||||
/// Per-second rates in hundredths of a US cent.
|
||||
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 735;
|
||||
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_634;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftViduQ3CostState {
|
||||
pub duration_seconds: u64,
|
||||
pub is_high_res: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftViduQ3CostState {
|
||||
pub fn from_request(request: &ArtcraftViduQ3RequestState) -> Self {
|
||||
Self {
|
||||
// Vidu Q3 defaults None → 5s.
|
||||
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
|
||||
is_high_res: is_high_res(request.request.resolution),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let rate = if self.is_high_res {
|
||||
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
|
||||
} else {
|
||||
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
|
||||
};
|
||||
// Round up to the next whole cent.
|
||||
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset)
|
||||
/// resolution is 720p.
|
||||
fn is_high_res(resolution: Option<CommonResolutionEnum>) -> bool {
|
||||
match resolution {
|
||||
None => true, // defaults to 720p
|
||||
Some(CommonResolutionEnum::SevenTwentyP)
|
||||
| Some(CommonResolutionEnum::TenEightyP)
|
||||
| Some(CommonResolutionEnum::OneK)
|
||||
| Some(CommonResolutionEnum::TwoK)
|
||||
| Some(CommonResolutionEnum::ThreeK)
|
||||
| Some(CommonResolutionEnum::FourK) => true,
|
||||
Some(CommonResolutionEnum::HalfK)
|
||||
| Some(CommonResolutionEnum::FourEightyP) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3_state;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::cost::ArtcraftViduQ3CostState;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<RouterResolution>) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
..Default::default()
|
||||
};
|
||||
let state = build_artcraft_vidu_q3_state(b).unwrap();
|
||||
ArtcraftViduQ3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_5s_is_82() { assert_eq!(cost_cents(Some(5), None), 82); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 82); }
|
||||
|
||||
#[test]
|
||||
fn high_res_10s_is_164() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 164); }
|
||||
|
||||
#[test]
|
||||
fn low_res_5s_is_37() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 37); }
|
||||
|
||||
#[test]
|
||||
fn low_res_10s_is_74() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 74); }
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_is_high_res_tier() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP)), 131);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_duration_rounds_up_to_whole_cents() {
|
||||
// 7s high res: 1634 * 7 = 11438 hundredth-cents → 115 cents.
|
||||
assert_eq!(cost_cents(Some(7), None), 115);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_classifier_defaults_high() {
|
||||
assert!(super::is_high_res(None));
|
||||
assert!(super::is_high_res(Some(CommonResolutionEnum::TenEightyP)));
|
||||
assert!(!super::is_high_res(Some(CommonResolutionEnum::FourEightyP)));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftViduQ3RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftViduQ3RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn keyframe_start_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn reference_images_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog explores a mossy forest clearing.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_vidu_q3_turbo(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_artcraft_vidu_q3_turbo_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3Turbo(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_artcraft_vidu_q3_turbo_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftViduQ3TurboRequestState, ArtcraftRouterError> {
|
||||
// Vidu Q3 Turbo supports `audio` — preserve it so cost can read it back.
|
||||
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
|
||||
let generate_audio = builder.generate_audio;
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::ViduQ3Turbo,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
request.generate_audio = generate_audio;
|
||||
Ok(ArtcraftViduQ3TurboRequestState { request })
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState;
|
||||
|
||||
/// Per-second rates in hundredths of a US cent.
|
||||
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 401;
|
||||
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 834;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftViduQ3TurboCostState {
|
||||
pub duration_seconds: u64,
|
||||
pub is_high_res: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftViduQ3TurboCostState {
|
||||
pub fn from_request(request: &ArtcraftViduQ3TurboRequestState) -> Self {
|
||||
Self {
|
||||
// Vidu Q3 Turbo defaults None → 5s.
|
||||
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
|
||||
is_high_res: is_high_res(request.request.resolution),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let rate = if self.is_high_res {
|
||||
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
|
||||
} else {
|
||||
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
|
||||
};
|
||||
// Round up to the next whole cent.
|
||||
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset)
|
||||
/// resolution is 720p.
|
||||
fn is_high_res(resolution: Option<CommonResolutionEnum>) -> bool {
|
||||
match resolution {
|
||||
None => true, // defaults to 720p
|
||||
Some(CommonResolutionEnum::SevenTwentyP)
|
||||
| Some(CommonResolutionEnum::TenEightyP)
|
||||
| Some(CommonResolutionEnum::OneK)
|
||||
| Some(CommonResolutionEnum::TwoK)
|
||||
| Some(CommonResolutionEnum::ThreeK)
|
||||
| Some(CommonResolutionEnum::FourK) => true,
|
||||
Some(CommonResolutionEnum::HalfK)
|
||||
| Some(CommonResolutionEnum::FourEightyP) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo_state;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::cost::ArtcraftViduQ3TurboCostState;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<RouterResolution>) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3Turbo,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
..Default::default()
|
||||
};
|
||||
let state = build_artcraft_vidu_q3_turbo_state(b).unwrap();
|
||||
ArtcraftViduQ3TurboCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_5s_is_42() { assert_eq!(cost_cents(Some(5), None), 42); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 42); }
|
||||
|
||||
#[test]
|
||||
fn high_res_10s_is_84() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 84); }
|
||||
|
||||
#[test]
|
||||
fn low_res_5s_is_21() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 21); }
|
||||
|
||||
#[test]
|
||||
fn low_res_10s_is_41() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 41); }
|
||||
|
||||
#[test]
|
||||
fn odd_duration_rounds_up_to_whole_cents() {
|
||||
// 7s high res: 834 * 7 = 5838 hundredth-cents → 59 cents.
|
||||
assert_eq!(cost_cents(Some(7), None), 59);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftViduQ3TurboRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftViduQ3TurboRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A paper boat drifting down a rainy gutter stream.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
|
||||
async fn keyframe_start_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog shakes off water in slow motion.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3Turbo,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+49
-48
@@ -1,10 +1,10 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_standard_image_to_video_webhook::{
|
||||
EnqueueKling3p0StandardImageToVideoAspectRatio, EnqueueKling3p0StandardImageToVideoDuration,
|
||||
EnqueueKling3p0StandardImageToVideoRequest,
|
||||
use fal_client::requests::api::video::image::kling_3p0_standard_image_to_video::api::{
|
||||
Kling3p0StandardImageToVideoAspectRatio, Kling3p0StandardImageToVideoDuration,
|
||||
Kling3p0StandardImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::{
|
||||
EnqueueKling3p0StandardTextToVideoAspectRatio, EnqueueKling3p0StandardTextToVideoDuration,
|
||||
EnqueueKling3p0StandardTextToVideoRequest,
|
||||
use fal_client::requests::api::video::text::kling_3p0_standard_text_to_video::api::{
|
||||
Kling3p0StandardTextToVideoAspectRatio, Kling3p0StandardTextToVideoDuration,
|
||||
Kling3p0StandardTextToVideoRequest,
|
||||
};
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -23,6 +23,13 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
||||
pub fn build_fal_kling_3p0_standard(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_kling_3p0_standard_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_kling_3p0_standard_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalKling3p0StandardRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
@@ -39,7 +46,7 @@ pub fn build_fal_kling_3p0_standard(
|
||||
value: "Kling 3.0 Standard requires a start_frame when end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
FalKling3p0StandardMode::TextToVideo(EnqueueKling3p0StandardTextToVideoRequest {
|
||||
FalKling3p0StandardMode::TextToVideo(Kling3p0StandardTextToVideoRequest {
|
||||
prompt,
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
@@ -48,7 +55,7 @@ pub fn build_fal_kling_3p0_standard(
|
||||
shot_type: None,
|
||||
})
|
||||
}
|
||||
Some(image_url) => FalKling3p0StandardMode::ImageToVideo(EnqueueKling3p0StandardImageToVideoRequest {
|
||||
Some(image_url) => FalKling3p0StandardMode::ImageToVideo(Kling3p0StandardImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
end_image_url: optional_url(builder.end_frame.clone())?,
|
||||
@@ -60,13 +67,11 @@ pub fn build_fal_kling_3p0_standard(
|
||||
}),
|
||||
};
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(
|
||||
FalKling3p0StandardRequestState { mode },
|
||||
)))
|
||||
Ok(FalKling3p0StandardRequestState { mode })
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardTextToVideoDuration {
|
||||
use EnqueueKling3p0StandardTextToVideoDuration as D;
|
||||
fn to_t2v_duration(d: PlanDuration) -> Kling3p0StandardTextToVideoDuration {
|
||||
use Kling3p0StandardTextToVideoDuration as D;
|
||||
match d.0 {
|
||||
3 => D::ThreeSeconds,
|
||||
4 => D::FourSeconds,
|
||||
@@ -84,8 +89,8 @@ fn to_t2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardTextToVideoDuratio
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardImageToVideoDuration {
|
||||
use EnqueueKling3p0StandardImageToVideoDuration as D;
|
||||
fn to_i2v_duration(d: PlanDuration) -> Kling3p0StandardImageToVideoDuration {
|
||||
use Kling3p0StandardImageToVideoDuration as D;
|
||||
match d.0 {
|
||||
3 => D::ThreeSeconds,
|
||||
4 => D::FourSeconds,
|
||||
@@ -103,19 +108,19 @@ fn to_i2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardImageToVideoDurati
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKling3p0StandardTextToVideoAspectRatio {
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling3p0StandardTextToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Square => EnqueueKling3p0StandardTextToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueKling3p0StandardTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueKling3p0StandardTextToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::Square => Kling3p0StandardTextToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => Kling3p0StandardTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Kling3p0StandardTextToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKling3p0StandardImageToVideoAspectRatio {
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Kling3p0StandardImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Square => EnqueueKling3p0StandardImageToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueKling3p0StandardImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueKling3p0StandardImageToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::Square => Kling3p0StandardImageToVideoAspectRatio::Square,
|
||||
PlanAspectRatio::SixteenByNine => Kling3p0StandardImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Kling3p0StandardImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +132,27 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_start_frame_picks_t2v() {
|
||||
let state = build_fal_kling_3p0_standard_state(base_builder()).expect("build");
|
||||
assert!(matches!(state.mode, FalKling3p0StandardMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
let state = build_fal_kling_3p0_standard_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalKling3p0StandardMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_kling_3p0_standard_state(b).is_err());
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Kling3p0Standard,
|
||||
@@ -135,29 +161,4 @@ mod tests {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_start_frame_picks_t2v() {
|
||||
let result = build_fal_kling_3p0_standard(base_builder()).expect("build");
|
||||
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(s)) = result {
|
||||
assert!(matches!(s.mode, FalKling3p0StandardMode::TextToVideo(_)));
|
||||
} else { panic!("expected FalKling3p0Standard"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
let result = build_fal_kling_3p0_standard(b).expect("build");
|
||||
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(s)) = result {
|
||||
assert!(matches!(s.mode, FalKling3p0StandardMode::ImageToVideo(_)));
|
||||
} else { panic!("expected FalKling3p0Standard"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_kling_3p0_standard(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+32
-74
@@ -1,5 +1,4 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_standard_image_to_video_webhook::EnqueueKling3p0StandardImageToVideoDuration;
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::EnqueueKling3p0StandardTextToVideoDuration;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::{
|
||||
@@ -8,35 +7,25 @@ use crate::generate::generate_video::providers::fal::kling_3p0_standard::request
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalKling3p0StandardCostState {
|
||||
pub duration_seconds: u64,
|
||||
pub generate_audio: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalKling3p0StandardCostState {
|
||||
pub fn from_request(request: &FalKling3p0StandardRequestState) -> Self {
|
||||
let (duration_seconds, generate_audio) = match &request.mode {
|
||||
FalKling3p0StandardMode::TextToVideo(req) => (
|
||||
t2v_duration_seconds(req.duration),
|
||||
req.generate_audio.unwrap_or(true),
|
||||
),
|
||||
FalKling3p0StandardMode::ImageToVideo(req) => (
|
||||
i2v_duration_seconds(req.duration),
|
||||
req.generate_audio.unwrap_or(true),
|
||||
),
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalKling3p0StandardMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalKling3p0StandardMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { duration_seconds, generate_audio }
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_3p0_standard:
|
||||
// audio off: $0.168/sec (rate=168)
|
||||
// audio on: $0.252/sec (rate=252)
|
||||
let rate: u64 = if self.generate_audio { 252 } else { 168 };
|
||||
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -46,64 +35,17 @@ impl FalKling3p0StandardCostState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default duration is 5s when None per Fal client.
|
||||
fn t2v_duration_seconds(d: Option<EnqueueKling3p0StandardTextToVideoDuration>) -> u64 {
|
||||
use EnqueueKling3p0StandardTextToVideoDuration as D;
|
||||
match d {
|
||||
None => 5,
|
||||
Some(D::ThreeSeconds) => 3,
|
||||
Some(D::FourSeconds) => 4,
|
||||
Some(D::FiveSeconds) => 5,
|
||||
Some(D::SixSeconds) => 6,
|
||||
Some(D::SevenSeconds) => 7,
|
||||
Some(D::EightSeconds) => 8,
|
||||
Some(D::NineSeconds) => 9,
|
||||
Some(D::TenSeconds) => 10,
|
||||
Some(D::ElevenSeconds) => 11,
|
||||
Some(D::TwelveSeconds) => 12,
|
||||
Some(D::ThirteenSeconds) => 13,
|
||||
Some(D::FourteenSeconds) => 14,
|
||||
Some(D::FifteenSeconds) => 15,
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_duration_seconds(d: Option<EnqueueKling3p0StandardImageToVideoDuration>) -> u64 {
|
||||
use EnqueueKling3p0StandardImageToVideoDuration as D;
|
||||
match d {
|
||||
None => 5,
|
||||
Some(D::ThreeSeconds) => 3,
|
||||
Some(D::FourSeconds) => 4,
|
||||
Some(D::FiveSeconds) => 5,
|
||||
Some(D::SixSeconds) => 6,
|
||||
Some(D::SevenSeconds) => 7,
|
||||
Some(D::EightSeconds) => 8,
|
||||
Some(D::NineSeconds) => 9,
|
||||
Some(D::TenSeconds) => 10,
|
||||
Some(D::ElevenSeconds) => 11,
|
||||
Some(D::TwelveSeconds) => 12,
|
||||
Some(D::ThirteenSeconds) => 13,
|
||||
Some(D::FourteenSeconds) => 14,
|
||||
Some(D::FifteenSeconds) => 15,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::kling_3p0_standard::build::build_fal_kling_3p0_standard_state;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Kling3p0Standard,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
}
|
||||
use super::*;
|
||||
|
||||
// Pricing (fal): audio off $0.168/sec (rate=168 tenths-of-cents), audio on
|
||||
// $0.252/sec (rate=252), rounded up to whole cents.
|
||||
|
||||
#[test]
|
||||
fn audio_on_5s_is_126() {
|
||||
@@ -127,4 +69,20 @@ mod tests {
|
||||
// (168*15 + 9) / 10 = 2529/10 = 252.
|
||||
assert_eq!(cost_cents(Some(15), Some(false)), 252);
|
||||
}
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Kling3p0Standard,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
let state = build_fal_kling_3p0_standard_state(b).expect("build_fal_kling_3p0_standard_state");
|
||||
FalKling3p0StandardCostState::from_request(&state)
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.expect("cost_in_usd_cents")
|
||||
}
|
||||
}
|
||||
|
||||
+54
-43
@@ -1,27 +1,20 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_standard_image_to_video_webhook::{
|
||||
enqueue_kling_3p0_standard_image_to_video_webhook, EnqueueKling3p0StandardImageToVideoArgs,
|
||||
EnqueueKling3p0StandardImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::{
|
||||
enqueue_kling_3p0_standard_text_to_video_webhook, EnqueueKling3p0StandardTextToVideoArgs,
|
||||
EnqueueKling3p0StandardTextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::image::kling_3p0_standard_image_to_video::api::Kling3p0StandardImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::kling_3p0_standard_text_to_video::api::Kling3p0StandardTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalKling3p0StandardMode {
|
||||
TextToVideo(EnqueueKling3p0StandardTextToVideoRequest),
|
||||
ImageToVideo(EnqueueKling3p0StandardImageToVideoRequest),
|
||||
TextToVideo(Kling3p0StandardTextToVideoRequest),
|
||||
ImageToVideo(Kling3p0StandardImageToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -31,38 +24,56 @@ pub struct FalKling3p0StandardRequestState {
|
||||
|
||||
impl FalKling3p0StandardRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
|
||||
FalKling3p0StandardMode::TextToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueKling3p0StandardTextToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_kling_3p0_standard_text_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalKling3p0StandardMode::ImageToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueKling3p0StandardImageToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_kling_3p0_standard_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
};
|
||||
match &self.mode {
|
||||
FalKling3p0StandardMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalKling3p0StandardMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let webhook_response = webhook_response
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
// ── Helpers ──
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -14,3 +14,6 @@ pub mod veo_3;
|
||||
pub mod veo_3_fast;
|
||||
pub mod veo_3p1;
|
||||
pub mod veo_3p1_fast;
|
||||
pub mod veo_3p1_lite;
|
||||
pub mod vidu_q3;
|
||||
pub mod vidu_q3_turbo;
|
||||
|
||||
+107
-74
@@ -1,7 +1,9 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
|
||||
Veo2AspectRatio, Veo2Duration, Veo2Request,
|
||||
use fal_client::requests::api::video::image::veo_2::api::{
|
||||
Veo2ImageToVideoDuration, Veo2ImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::text::veo_2::api::{
|
||||
Veo2TextToVideoAspectRatio, Veo2TextToVideoDuration, Veo2TextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_2_text_to_video_webhook::Veo2TextToVideoRequest;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
@@ -13,9 +15,26 @@ use crate::generate::generate_video::providers::fal::veo_2::request::{FalVeo2Mod
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
/// Router-level duration plan shared by both modes — each mode maps it onto
|
||||
/// its own fal duration enum.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanDuration {
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
pub fn build_fal_veo_2(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_veo_2_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_veo_2_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalVeo2RequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
if builder.end_frame.is_some() {
|
||||
@@ -27,10 +46,10 @@ pub fn build_fal_veo_2(
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
|
||||
let mode = match builder.start_frame.clone() {
|
||||
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo(Veo2Request {
|
||||
image_url: url,
|
||||
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo(Veo2ImageToVideoRequest {
|
||||
prompt,
|
||||
duration,
|
||||
image_url: url,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
}),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
@@ -39,38 +58,41 @@ pub fn build_fal_veo_2(
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
FalVeo2Mode::TextToVideo(Veo2TextToVideoRequest {
|
||||
prompt,
|
||||
negative_prompt,
|
||||
duration,
|
||||
aspect_ratio,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
negative_prompt,
|
||||
enhance_prompt: None,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(
|
||||
FalVeo2RequestState { mode },
|
||||
)))
|
||||
Ok(FalVeo2RequestState { mode })
|
||||
}
|
||||
|
||||
/// `None` lets fal pick its default (16:9); the old endpoint's explicit "auto"
|
||||
/// no longer exists in the new API, so Auto maps to unset.
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo2AspectRatio, ArtcraftRouterError> {
|
||||
use Veo2AspectRatio as Ar;
|
||||
) -> Result<Option<Veo2TextToVideoAspectRatio>, ArtcraftRouterError> {
|
||||
use Veo2TextToVideoAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
|
||||
Some(unsupported_ar) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
|
||||
}
|
||||
_ => Ok(Ar::Auto),
|
||||
_ => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -78,19 +100,19 @@ fn plan_aspect_ratio(
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo2Duration, ArtcraftRouterError> {
|
||||
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(Veo2Duration::Default),
|
||||
Some(5) => Ok(Veo2Duration::FiveSeconds),
|
||||
Some(6) => Ok(Veo2Duration::SixSeconds),
|
||||
Some(7) => Ok(Veo2Duration::SevenSeconds),
|
||||
Some(8) => Ok(Veo2Duration::EightSeconds),
|
||||
None => Ok(None),
|
||||
Some(5) => Ok(Some(PlanDuration::Five)),
|
||||
Some(6) => Ok(Some(PlanDuration::Six)),
|
||||
Some(7) => Ok(Some(PlanDuration::Seven)),
|
||||
Some(8) => Ok(Some(PlanDuration::Eight)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo2Duration::EightSeconds),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo2Duration::FiveSeconds),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Eight)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -102,45 +124,33 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
})
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> Veo2TextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => Veo2TextToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Six => Veo2TextToVideoDuration::SixSeconds,
|
||||
PlanDuration::Seven => Veo2TextToVideoDuration::SevenSeconds,
|
||||
PlanDuration::Eight => Veo2TextToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> Veo2ImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Five => Veo2ImageToVideoDuration::FiveSeconds,
|
||||
PlanDuration::Six => Veo2ImageToVideoDuration::SixSeconds,
|
||||
PlanDuration::Seven => Veo2ImageToVideoDuration::SevenSeconds,
|
||||
PlanDuration::Eight => Veo2ImageToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo2,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("a corgi running".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo2TextToVideoRequest {
|
||||
match build_fal_veo_2(b).expect("build_fal_veo_2") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(state)) => match state.mode {
|
||||
FalVeo2Mode::TextToVideo(r) => r,
|
||||
FalVeo2Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
|
||||
},
|
||||
_ => panic!("expected FalVeo2 request"),
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo2Request {
|
||||
match build_fal_veo_2(b).expect("build_fal_veo_2") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(state)) => match state.mode {
|
||||
FalVeo2Mode::ImageToVideo(r) => r,
|
||||
FalVeo2Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
|
||||
},
|
||||
_ => panic!("expected FalVeo2 request"),
|
||||
}
|
||||
}
|
||||
|
||||
mod mode_selection {
|
||||
use super::*;
|
||||
|
||||
@@ -160,14 +170,14 @@ mod tests {
|
||||
fn end_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_veo_2(b).is_err());
|
||||
assert!(build_fal_veo_2_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_file_token_for_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string())));
|
||||
assert!(build_fal_veo_2(b).is_err());
|
||||
assert!(build_fal_veo_2_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,22 +210,22 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_none_is_default() {
|
||||
assert!(matches!(t2v_request(base_builder()).duration, Veo2Duration::Default));
|
||||
fn duration_none_is_unset() {
|
||||
// fal defaults to 5s when unset.
|
||||
assert!(t2v_request(base_builder()).duration.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5_through_8() {
|
||||
for (seconds, expected) in [
|
||||
(5, Veo2Duration::FiveSeconds),
|
||||
(6, Veo2Duration::SixSeconds),
|
||||
(7, Veo2Duration::SevenSeconds),
|
||||
(8, Veo2Duration::EightSeconds),
|
||||
(5, Veo2TextToVideoDuration::FiveSeconds),
|
||||
(6, Veo2TextToVideoDuration::SixSeconds),
|
||||
(7, Veo2TextToVideoDuration::SevenSeconds),
|
||||
(8, Veo2TextToVideoDuration::EightSeconds),
|
||||
] {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(seconds);
|
||||
let got = t2v_request(b).duration;
|
||||
assert_eq!(format!("{:?}", got), format!("{:?}", expected));
|
||||
assert_eq!(t2v_request(b).duration, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +234,7 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(10);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_2(b).is_err());
|
||||
assert!(build_fal_veo_2_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -232,7 +242,7 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(20);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
assert!(matches!(t2v_request(b).duration, Veo2Duration::EightSeconds));
|
||||
assert_eq!(t2v_request(b).duration, Some(Veo2TextToVideoDuration::EightSeconds));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,7 +250,7 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(2);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
assert!(matches!(t2v_request(b).duration, Veo2Duration::FiveSeconds));
|
||||
assert_eq!(t2v_request(b).duration, Some(Veo2TextToVideoDuration::FiveSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,17 +259,17 @@ mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
|
||||
#[test]
|
||||
fn t2v_auto() {
|
||||
fn t2v_auto_is_unset() {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
assert!(matches!(t2v_request(b).aspect_ratio, Veo2AspectRatio::Auto));
|
||||
assert!(t2v_request(b).aspect_ratio.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_sixteen_nine() {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
|
||||
assert!(matches!(t2v_request(b).aspect_ratio, Veo2AspectRatio::WideSixteenNine));
|
||||
assert_eq!(t2v_request(b).aspect_ratio, Some(Veo2TextToVideoAspectRatio::SixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -267,7 +277,7 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Square);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
assert!(matches!(t2v_request(b).aspect_ratio, Veo2AspectRatio::Auto));
|
||||
assert!(t2v_request(b).aspect_ratio.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -275,7 +285,7 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Square);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_2(b).is_err());
|
||||
assert!(build_fal_veo_2_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,11 +305,34 @@ mod tests {
|
||||
if has_start_frame {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
assert!(build_fal_veo_2(b).is_ok());
|
||||
assert!(build_fal_veo_2_state(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(combos, 4 * 5 * 2);
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo2,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("a corgi running".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo2TextToVideoRequest {
|
||||
match build_fal_veo_2_state(b).expect("build_fal_veo_2_state").mode {
|
||||
FalVeo2Mode::TextToVideo(r) => r,
|
||||
FalVeo2Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo2ImageToVideoRequest {
|
||||
match build_fal_veo_2_state(b).expect("build_fal_veo_2_state").mode {
|
||||
FalVeo2Mode::ImageToVideo(r) => r,
|
||||
FalVeo2Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-40
@@ -1,31 +1,29 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::Veo2Duration;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::veo_2::request::{FalVeo2Mode, FalVeo2RequestState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo2CostState {
|
||||
pub duration: Veo2Duration,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalVeo2CostState {
|
||||
pub fn from_request(request: &FalVeo2RequestState) -> Self {
|
||||
let duration = match &request.mode {
|
||||
FalVeo2Mode::TextToVideo(req) => req.duration,
|
||||
FalVeo2Mode::ImageToVideo(req) => req.duration,
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalVeo2Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo2Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { duration }
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// 5s = $2.50, +$0.50 per additional second.
|
||||
let seconds = duration_seconds_for_cost(self.duration);
|
||||
let extra = seconds.saturating_sub(5);
|
||||
let cost_in_usd_cents = 250 + extra * 50;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -35,40 +33,18 @@ impl FalVeo2CostState {
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_seconds_for_cost(d: Veo2Duration) -> u64 {
|
||||
match d {
|
||||
Veo2Duration::Default | Veo2Duration::FiveSeconds => 5,
|
||||
Veo2Duration::SixSeconds => 6,
|
||||
Veo2Duration::SevenSeconds => 7,
|
||||
Veo2Duration::EightSeconds => 8,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_2::build::build_fal_veo_2_state;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, has_start_frame: bool) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo2,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
..Default::default()
|
||||
};
|
||||
if has_start_frame {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
b.build2()
|
||||
.expect("build2")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost")
|
||||
.cost_in_usd_cents
|
||||
.expect("cost_in_usd_cents")
|
||||
}
|
||||
use super::*;
|
||||
|
||||
// Pricing (fal): 5s = $2.50, +$0.50 per additional second. Flat across
|
||||
// modalities; no audio or resolution knobs.
|
||||
|
||||
mod numeric_literal_pricing {
|
||||
use super::*;
|
||||
@@ -112,4 +88,22 @@ mod tests {
|
||||
assert!(cost_cents(Some(6), false) < cost_cents(Some(7), false));
|
||||
assert!(cost_cents(Some(7), false) < cost_cents(Some(8), false));
|
||||
}
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, has_start_frame: bool) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo2,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
..Default::default()
|
||||
};
|
||||
if has_start_frame {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
let state = build_fal_veo_2_state(b).expect("build_fal_veo_2_state");
|
||||
FalVeo2CostState::from_request(&state)
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.expect("cost_in_usd_cents")
|
||||
}
|
||||
}
|
||||
|
||||
+90
-79
@@ -1,17 +1,12 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
|
||||
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2Request,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
|
||||
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2TextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::image::veo_2::api::Veo2ImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::veo_2::api::Veo2TextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
@@ -19,7 +14,7 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalVeo2Mode {
|
||||
TextToVideo(Veo2TextToVideoRequest),
|
||||
ImageToVideo(Veo2Request),
|
||||
ImageToVideo(Veo2ImageToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -29,96 +24,112 @@ pub struct FalVeo2RequestState {
|
||||
|
||||
impl FalVeo2RequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
|
||||
FalVeo2Mode::TextToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = Veo2TextToVideoArgs {
|
||||
request: request.clone(),
|
||||
api_key: &client.api_key,
|
||||
webhook_url,
|
||||
};
|
||||
(enqueue_veo_2_text_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalVeo2Mode::ImageToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = Veo2Args {
|
||||
request: request.clone(),
|
||||
api_key: &client.api_key,
|
||||
webhook_url,
|
||||
};
|
||||
(enqueue_veo_2_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
};
|
||||
match &self.mode {
|
||||
FalVeo2Mode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalVeo2Mode::ImageToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let webhook_response = webhook_response
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
// ── Helpers ──
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::image::veo_2::api::Veo2ImageToVideoDuration;
|
||||
use fal_client::requests::api::video::text::veo_2::api::{
|
||||
Veo2TextToVideoAspectRatio, Veo2TextToVideoDuration,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::test_helpers::get_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_text_to_video_5s() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("a peaceful lake at dawn".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
duration_seconds: Some(5),
|
||||
..fal_veo_2_builder()
|
||||
}).await;
|
||||
println!("response: {:?}", response);
|
||||
let state = FalVeo2RequestState {
|
||||
mode: FalVeo2Mode::TextToVideo(Veo2TextToVideoRequest {
|
||||
prompt: "a peaceful lake at dawn".to_string(),
|
||||
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
|
||||
duration: Some(Veo2TextToVideoDuration::FiveSeconds),
|
||||
negative_prompt: None,
|
||||
enhance_prompt: None,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_image_to_video_7s() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog leaps into the lake and splashes around.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
duration_seconds: Some(7),
|
||||
..fal_veo_2_builder()
|
||||
}).await;
|
||||
println!("response: {:?}", response);
|
||||
let state = FalVeo2RequestState {
|
||||
mode: FalVeo2Mode::ImageToVideo(Veo2ImageToVideoRequest {
|
||||
prompt: "the dog leaps into the lake and splashes around.".to_string(),
|
||||
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
duration: Some(Veo2ImageToVideoDuration::SevenSeconds),
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
fn fal_veo_2_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo2,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_fal_client();
|
||||
let draft_or_request = builder.build2().expect("build2");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant"),
|
||||
};
|
||||
request.send_request(&client).await.expect("send_request")
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+116
-118
@@ -1,8 +1,10 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
|
||||
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution, Veo3Request,
|
||||
use fal_client::requests::api::video::image::veo_3::api::{
|
||||
Veo3ImageToVideoAspectRatio, Veo3ImageToVideoDuration, Veo3ImageToVideoRequest,
|
||||
Veo3ImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
|
||||
Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution, Veo3TextToVideoRequest,
|
||||
use fal_client::requests::api::video::text::veo_3::api::{
|
||||
Veo3TextToVideoAspectRatio, Veo3TextToVideoDuration, Veo3TextToVideoRequest,
|
||||
Veo3TextToVideoResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
@@ -16,18 +18,16 @@ use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mod
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
/// Router-level resolution shared between both modes — kept here so the cost
|
||||
/// calculator can reconstruct it from either mode's request.
|
||||
/// Router-level resolution shared between both modes — each mode maps it onto
|
||||
/// its own fal resolution enum.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanResolution {
|
||||
Default,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanDuration {
|
||||
Default,
|
||||
FourSeconds,
|
||||
SixSeconds,
|
||||
EightSeconds,
|
||||
@@ -36,6 +36,13 @@ pub(crate) enum PlanDuration {
|
||||
pub fn build_fal_veo_3(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_veo_3_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_veo_3_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalVeo3RequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
if builder.end_frame.is_some() {
|
||||
@@ -46,18 +53,24 @@ pub fn build_fal_veo_3(
|
||||
let negative_prompt = builder.negative_prompt.clone();
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let generate_audio = builder.generate_audio.unwrap_or(true);
|
||||
// fal's server default is audio-on; keep the explicit `true` the old
|
||||
// endpoint sent so pricing stays deterministic.
|
||||
let generate_audio = Some(builder.generate_audio.unwrap_or(true));
|
||||
|
||||
let mode = match builder.start_frame.clone() {
|
||||
Some(ImageRef::Url(url)) => {
|
||||
let i2v_aspect_ratio = plan_i2v_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
FalVeo3Mode::ImageToVideo(Veo3Request {
|
||||
image_url: url,
|
||||
FalVeo3Mode::ImageToVideo(Veo3ImageToVideoRequest {
|
||||
prompt,
|
||||
duration: to_i2v_duration(duration),
|
||||
image_url: url,
|
||||
aspect_ratio: i2v_aspect_ratio,
|
||||
resolution: to_i2v_resolution(resolution),
|
||||
duration: duration.map(to_i2v_duration),
|
||||
resolution: resolution.map(to_i2v_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
}
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
@@ -67,35 +80,37 @@ pub fn build_fal_veo_3(
|
||||
let t2v_aspect_ratio = plan_t2v_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
FalVeo3Mode::TextToVideo(Veo3TextToVideoRequest {
|
||||
prompt,
|
||||
negative_prompt,
|
||||
duration: to_t2v_duration(duration),
|
||||
aspect_ratio: t2v_aspect_ratio,
|
||||
resolution: to_t2v_resolution(resolution),
|
||||
duration: duration.map(to_t2v_duration),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(
|
||||
FalVeo3RequestState { mode },
|
||||
)))
|
||||
Ok(FalVeo3RequestState { mode })
|
||||
}
|
||||
|
||||
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square).
|
||||
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square). `None` lets fal
|
||||
/// pick its default (16:9).
|
||||
fn plan_t2v_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3T2vAspectRatio, ArtcraftRouterError> {
|
||||
use Veo3T2vAspectRatio as Ar;
|
||||
) -> Result<Option<Veo3TextToVideoAspectRatio>, ArtcraftRouterError> {
|
||||
use Veo3TextToVideoAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(Ar::Default),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
None => Ok(None),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
|
||||
}
|
||||
_ => Ok(Ar::Default),
|
||||
_ => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -104,23 +119,23 @@ fn plan_t2v_aspect_ratio(
|
||||
fn plan_i2v_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3I2vAspectRatio, ArtcraftRouterError> {
|
||||
use Veo3I2vAspectRatio as Ar;
|
||||
) -> Result<Option<Veo3ImageToVideoAspectRatio>, ArtcraftRouterError> {
|
||||
use Veo3ImageToVideoAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
|
||||
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
|
||||
}
|
||||
_ => Ok(Ar::Auto),
|
||||
_ => Ok(Some(Ar::Auto)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -128,17 +143,17 @@ fn plan_i2v_aspect_ratio(
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<PlanResolution, ArtcraftRouterError> {
|
||||
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(PlanResolution::Default),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(PlanResolution::SevenTwentyP),
|
||||
Some(RouterResolution::TenEightyP) => Ok(PlanResolution::TenEightyP),
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", &format!("{:?}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(PlanResolution::TenEightyP),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(PlanResolution::SevenTwentyP),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::SevenTwentyP)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -146,18 +161,18 @@ fn plan_resolution(
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<PlanDuration, ArtcraftRouterError> {
|
||||
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(PlanDuration::Default),
|
||||
Some(4) => Ok(PlanDuration::FourSeconds),
|
||||
Some(6) => Ok(PlanDuration::SixSeconds),
|
||||
Some(8) => Ok(PlanDuration::EightSeconds),
|
||||
None => Ok(None),
|
||||
Some(4) => Ok(Some(PlanDuration::FourSeconds)),
|
||||
Some(6) => Ok(Some(PlanDuration::SixSeconds)),
|
||||
Some(8) => Ok(Some(PlanDuration::EightSeconds)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(PlanDuration::EightSeconds),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(PlanDuration::FourSeconds),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::EightSeconds)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::FourSeconds)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -169,37 +184,33 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
})
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> Veo3T2vDuration {
|
||||
fn to_t2v_duration(d: PlanDuration) -> Veo3TextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Default => Veo3T2vDuration::Default,
|
||||
PlanDuration::FourSeconds => Veo3T2vDuration::FourSeconds,
|
||||
PlanDuration::SixSeconds => Veo3T2vDuration::SixSeconds,
|
||||
PlanDuration::EightSeconds => Veo3T2vDuration::EightSeconds,
|
||||
PlanDuration::FourSeconds => Veo3TextToVideoDuration::FourSeconds,
|
||||
PlanDuration::SixSeconds => Veo3TextToVideoDuration::SixSeconds,
|
||||
PlanDuration::EightSeconds => Veo3TextToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> Veo3I2vDuration {
|
||||
fn to_i2v_duration(d: PlanDuration) -> Veo3ImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Default => Veo3I2vDuration::Default,
|
||||
PlanDuration::FourSeconds => Veo3I2vDuration::FourSeconds,
|
||||
PlanDuration::SixSeconds => Veo3I2vDuration::SixSeconds,
|
||||
PlanDuration::EightSeconds => Veo3I2vDuration::EightSeconds,
|
||||
PlanDuration::FourSeconds => Veo3ImageToVideoDuration::FourSeconds,
|
||||
PlanDuration::SixSeconds => Veo3ImageToVideoDuration::SixSeconds,
|
||||
PlanDuration::EightSeconds => Veo3ImageToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: PlanResolution) -> Veo3T2vResolution {
|
||||
fn to_t2v_resolution(r: PlanResolution) -> Veo3TextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::Default => Veo3T2vResolution::Default,
|
||||
PlanResolution::SevenTwentyP => Veo3T2vResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3T2vResolution::TenEightyP,
|
||||
PlanResolution::SevenTwentyP => Veo3TextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3TextToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: PlanResolution) -> Veo3I2vResolution {
|
||||
fn to_i2v_resolution(r: PlanResolution) -> Veo3ImageToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::Default => Veo3I2vResolution::Default,
|
||||
PlanResolution::SevenTwentyP => Veo3I2vResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3I2vResolution::TenEightyP,
|
||||
PlanResolution::SevenTwentyP => Veo3ImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3ImageToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,48 +218,9 @@ fn to_i2v_resolution(r: PlanResolution) -> Veo3I2vResolution {
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn base_t2v_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn base_i2v_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
|
||||
..base_t2v_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3TextToVideoRequest {
|
||||
match build_fal_veo_3(b).expect("build") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(state)) => match state.mode {
|
||||
FalVeo3Mode::TextToVideo(r) => r,
|
||||
FalVeo3Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
|
||||
},
|
||||
_ => panic!("expected FalVeo3"),
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3Request {
|
||||
match build_fal_veo_3(b).expect("build") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(state)) => match state.mode {
|
||||
FalVeo3Mode::ImageToVideo(r) => r,
|
||||
FalVeo3Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
|
||||
},
|
||||
_ => panic!("expected FalVeo3"),
|
||||
}
|
||||
}
|
||||
|
||||
use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mode, FalVeo3RequestState};
|
||||
|
||||
mod mode_selection {
|
||||
use super::*;
|
||||
|
||||
@@ -262,7 +234,7 @@ mod tests {
|
||||
fn end_frame_errors() {
|
||||
let mut b = base_t2v_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_veo_3(b).is_err());
|
||||
assert!(build_fal_veo_3_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,14 +245,14 @@ mod tests {
|
||||
fn t2v_720p() {
|
||||
let mut b = base_t2v_builder();
|
||||
b.resolution = Some(RouterResolution::SevenTwentyP);
|
||||
assert!(matches!(t2v_request(b).resolution, Veo3T2vResolution::SevenTwentyP));
|
||||
assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_1080p() {
|
||||
let mut b = base_t2v_builder();
|
||||
b.resolution = Some(RouterResolution::TenEightyP);
|
||||
assert!(matches!(t2v_request(b).resolution, Veo3T2vResolution::TenEightyP));
|
||||
assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -288,7 +260,7 @@ mod tests {
|
||||
let mut b = base_t2v_builder();
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
assert!(matches!(t2v_request(b).resolution, Veo3T2vResolution::TenEightyP));
|
||||
assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::TenEightyP));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,13 +270,13 @@ mod tests {
|
||||
#[test]
|
||||
fn t2v_4s_6s_8s() {
|
||||
for (s, expected) in [
|
||||
(4, Veo3T2vDuration::FourSeconds),
|
||||
(6, Veo3T2vDuration::SixSeconds),
|
||||
(8, Veo3T2vDuration::EightSeconds),
|
||||
(4, Veo3TextToVideoDuration::FourSeconds),
|
||||
(6, Veo3TextToVideoDuration::SixSeconds),
|
||||
(8, Veo3TextToVideoDuration::EightSeconds),
|
||||
] {
|
||||
let mut b = base_t2v_builder();
|
||||
b.duration_seconds = Some(s);
|
||||
assert_eq!(format!("{:?}", t2v_request(b).duration), format!("{:?}", expected));
|
||||
assert_eq!(t2v_request(b).duration, Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,12 +288,12 @@ mod tests {
|
||||
fn t2v_sixteen_nine() {
|
||||
let mut b = base_t2v_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
|
||||
assert!(matches!(t2v_request(b).aspect_ratio, Veo3T2vAspectRatio::WideSixteenNine));
|
||||
assert_eq!(t2v_request(b).aspect_ratio, Some(Veo3TextToVideoAspectRatio::SixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_auto_when_unspecified() {
|
||||
assert!(matches!(i2v_request(base_i2v_builder()).aspect_ratio, Veo3I2vAspectRatio::Auto));
|
||||
assert_eq!(i2v_request(base_i2v_builder()).aspect_ratio, Some(Veo3ImageToVideoAspectRatio::Auto));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,14 +302,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn audio_defaults_to_true() {
|
||||
assert!(t2v_request(base_t2v_builder()).generate_audio);
|
||||
assert_eq!(t2v_request(base_t2v_builder()).generate_audio, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_false_is_passed_through() {
|
||||
let mut b = base_t2v_builder();
|
||||
b.generate_audio = Some(false);
|
||||
assert!(!t2v_request(b).generate_audio);
|
||||
assert_eq!(t2v_request(b).generate_audio, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +331,7 @@ mod tests {
|
||||
b.duration_seconds = duration;
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.generate_audio = generate_audio;
|
||||
assert!(build_fal_veo_3(b).is_ok());
|
||||
assert!(build_fal_veo_3_state(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
}
|
||||
@@ -369,7 +341,33 @@ mod tests {
|
||||
assert_eq!(combos, 3 * 4 * 4 * 3 * 2);
|
||||
}
|
||||
|
||||
// Silence unused warnings for FalVeo3RequestState in this scope.
|
||||
#[allow(dead_code)]
|
||||
fn _silence(_: FalVeo3RequestState) {}
|
||||
fn base_t2v_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn base_i2v_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
|
||||
..base_t2v_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3TextToVideoRequest {
|
||||
match build_fal_veo_3_state(b).expect("build_fal_veo_3_state").mode {
|
||||
FalVeo3Mode::TextToVideo(r) => r,
|
||||
FalVeo3Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3ImageToVideoRequest {
|
||||
match build_fal_veo_3_state(b).expect("build_fal_veo_3_state").mode {
|
||||
FalVeo3Mode::ImageToVideo(r) => r,
|
||||
FalVeo3Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-45
@@ -1,32 +1,29 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_image_to_video_webhook::Veo3I2vDuration;
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3_text_to_video_webhook::Veo3T2vDuration;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mode, FalVeo3RequestState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo3CostState {
|
||||
pub duration_seconds: u64,
|
||||
pub generate_audio: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalVeo3CostState {
|
||||
pub fn from_request(request: &FalVeo3RequestState) -> Self {
|
||||
let (duration_seconds, generate_audio) = match &request.mode {
|
||||
FalVeo3Mode::TextToVideo(req) => (t2v_duration_seconds(req.duration), req.generate_audio),
|
||||
FalVeo3Mode::ImageToVideo(req) => (i2v_duration_seconds(req.duration), req.generate_audio),
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalVeo3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { duration_seconds, generate_audio }
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// $0.20/sec audio off, $0.40/sec audio on (720p/1080p).
|
||||
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -36,43 +33,18 @@ impl FalVeo3CostState {
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_duration_seconds(d: Veo3T2vDuration) -> u64 {
|
||||
match d {
|
||||
Veo3T2vDuration::Default | Veo3T2vDuration::EightSeconds => 8,
|
||||
Veo3T2vDuration::SixSeconds => 6,
|
||||
Veo3T2vDuration::FourSeconds => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_duration_seconds(d: Veo3I2vDuration) -> u64 {
|
||||
match d {
|
||||
Veo3I2vDuration::Default | Veo3I2vDuration::EightSeconds => 8,
|
||||
Veo3I2vDuration::SixSeconds => 6,
|
||||
Veo3I2vDuration::FourSeconds => 4,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3::build::build_fal_veo_3_state;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, has_start_frame: bool) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
if has_start_frame {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
|
||||
}
|
||||
use super::*;
|
||||
|
||||
// Pricing (fal): $0.20/sec audio off, $0.40/sec audio on. Flat across
|
||||
// modalities; resolution (720p/1080p) does not change the price.
|
||||
|
||||
mod numeric_literal_pricing {
|
||||
use super::*;
|
||||
@@ -100,7 +72,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn duration_default_is_8s() {
|
||||
// No duration → Default → 8s.
|
||||
// No duration → fal default 8s.
|
||||
assert_eq!(cost_cents(None, Some(true), false), 320);
|
||||
}
|
||||
|
||||
@@ -120,4 +92,23 @@ mod tests {
|
||||
fn audio_costs_more_than_no_audio() {
|
||||
assert!(cost_cents(Some(8), Some(false), false) < cost_cents(Some(8), Some(true), false));
|
||||
}
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, has_start_frame: bool) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
if has_start_frame {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
let state = build_fal_veo_3_state(b).expect("build_fal_veo_3_state");
|
||||
FalVeo3CostState::from_request(&state)
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.expect("cost_in_usd_cents")
|
||||
}
|
||||
}
|
||||
|
||||
+106
-85
@@ -1,17 +1,12 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
|
||||
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3Request,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
|
||||
enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3TextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::image::veo_3::api::Veo3ImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::veo_3::api::Veo3TextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
@@ -19,7 +14,7 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalVeo3Mode {
|
||||
TextToVideo(Veo3TextToVideoRequest),
|
||||
ImageToVideo(Veo3Request),
|
||||
ImageToVideo(Veo3ImageToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -29,97 +24,123 @@ pub struct FalVeo3RequestState {
|
||||
|
||||
impl FalVeo3RequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
|
||||
FalVeo3Mode::TextToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = Veo3TextToVideoArgs {
|
||||
request: request.clone(),
|
||||
api_key: &client.api_key,
|
||||
webhook_url,
|
||||
};
|
||||
(enqueue_veo_3_text_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalVeo3Mode::ImageToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = Veo3Args {
|
||||
request: request.clone(),
|
||||
api_key: &client.api_key,
|
||||
webhook_url,
|
||||
};
|
||||
(enqueue_veo_3_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
};
|
||||
match &self.mode {
|
||||
FalVeo3Mode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3Mode::ImageToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let webhook_response = webhook_response
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
// ── Helpers ──
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::image::veo_3::api::{
|
||||
Veo3ImageToVideoDuration, Veo3ImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::text::veo_3::api::{
|
||||
Veo3TextToVideoDuration, Veo3TextToVideoResolution,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::test_helpers::get_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_text_to_video_720p_4s_audio_on() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("a serene mountain landscape".to_string()),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(4),
|
||||
generate_audio: Some(true),
|
||||
..builder()
|
||||
}).await;
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
let state = FalVeo3RequestState {
|
||||
mode: FalVeo3Mode::TextToVideo(Veo3TextToVideoRequest {
|
||||
prompt: "a serene mountain landscape".to_string(),
|
||||
aspect_ratio: None,
|
||||
duration: Some(Veo3TextToVideoDuration::FourSeconds),
|
||||
resolution: Some(Veo3TextToVideoResolution::SevenTwentyP),
|
||||
negative_prompt: None,
|
||||
generate_audio: Some(true),
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_image_to_video_1080p_8s_no_audio() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog leaps into the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
duration_seconds: Some(8),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
}).await;
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
fn builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_pipeline(b: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_fal_client();
|
||||
let dor = b.build2().expect("build2");
|
||||
let req = match dor {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request"),
|
||||
let state = FalVeo3RequestState {
|
||||
mode: FalVeo3Mode::ImageToVideo(Veo3ImageToVideoRequest {
|
||||
prompt: "the dog leaps into the lake.".to_string(),
|
||||
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
aspect_ratio: None,
|
||||
duration: Some(Veo3ImageToVideoDuration::EightSeconds),
|
||||
resolution: Some(Veo3ImageToVideoResolution::TenEightyP),
|
||||
generate_audio: Some(false),
|
||||
negative_prompt: None,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
};
|
||||
req.send_request(&client).await.expect("send")
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+229
-94
@@ -1,5 +1,10 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
|
||||
Veo3FastAspectRatio, Veo3FastDuration, Veo3FastRequest, Veo3FastResolution,
|
||||
use fal_client::requests::api::video::image::veo_3_fast::api::{
|
||||
Veo3FastImageToVideoAspectRatio, Veo3FastImageToVideoDuration, Veo3FastImageToVideoRequest,
|
||||
Veo3FastImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::text::veo_3_fast::api::{
|
||||
Veo3FastTextToVideoAspectRatio, Veo3FastTextToVideoDuration, Veo3FastTextToVideoRequest,
|
||||
Veo3FastTextToVideoResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
@@ -9,69 +14,130 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3_fast::request::FalVeo3FastRequestState;
|
||||
use crate::generate::generate_video::providers::fal::veo_3_fast::request::{
|
||||
FalVeo3FastMode, FalVeo3FastRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
/// Router-level resolution shared between both modes — each mode maps it onto
|
||||
/// its own fal resolution enum.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanResolution {
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanDuration {
|
||||
FourSeconds,
|
||||
SixSeconds,
|
||||
EightSeconds,
|
||||
}
|
||||
|
||||
pub fn build_fal_veo_3_fast(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_veo_3_fast_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3Fast(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_veo_3_fast_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalVeo3FastRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start_frame_url = require_url(builder.start_frame.clone())?;
|
||||
if builder.end_frame.is_some() {
|
||||
return Err(unsupported("end_frame", "Veo 3 Fast does not support an ending frame"));
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let negative_prompt = builder.negative_prompt.clone();
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let generate_audio = builder.generate_audio.unwrap_or(true);
|
||||
// fal's server default is audio-on; keep the explicit `true` the old
|
||||
// endpoint sent so pricing stays deterministic.
|
||||
let generate_audio = Some(builder.generate_audio.unwrap_or(true));
|
||||
|
||||
let request = Veo3FastRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
image_url: start_frame_url,
|
||||
aspect_ratio,
|
||||
duration,
|
||||
resolution,
|
||||
generate_audio,
|
||||
let mode = match builder.start_frame.clone() {
|
||||
Some(ImageRef::Url(url)) => {
|
||||
let i2v_aspect_ratio = plan_i2v_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
FalVeo3FastMode::ImageToVideo(Veo3FastImageToVideoRequest {
|
||||
prompt,
|
||||
image_url: url,
|
||||
aspect_ratio: i2v_aspect_ratio,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
resolution: resolution.map(to_i2v_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
}
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
None => {
|
||||
let t2v_aspect_ratio = plan_t2v_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
FalVeo3FastMode::TextToVideo(Veo3FastTextToVideoRequest {
|
||||
prompt,
|
||||
aspect_ratio: t2v_aspect_ratio,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3Fast(
|
||||
FalVeo3FastRequestState { request },
|
||||
)))
|
||||
Ok(FalVeo3FastRequestState { mode })
|
||||
}
|
||||
|
||||
fn require_url(image_ref: Option<ImageRef>) -> Result<String, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
Some(ImageRef::Url(url)) => Ok(url),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
None => Err(unsupported("start_frame", "Veo 3 Fast requires a starting frame")),
|
||||
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square). `None` lets fal
|
||||
/// pick its default (16:9).
|
||||
fn plan_t2v_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Veo3FastTextToVideoAspectRatio>, ArtcraftRouterError> {
|
||||
use Veo3FastTextToVideoAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(None),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
|
||||
}
|
||||
_ => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
/// Image-to-video: Auto, 16:9, 9:16 (no Square).
|
||||
fn plan_i2v_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3FastAspectRatio, ArtcraftRouterError> {
|
||||
use Veo3FastAspectRatio as Ar;
|
||||
) -> Result<Option<Veo3FastImageToVideoAspectRatio>, ArtcraftRouterError> {
|
||||
use Veo3FastImageToVideoAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
|
||||
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
|
||||
Some(unsupported_ar) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
|
||||
}
|
||||
_ => Ok(Ar::Auto),
|
||||
_ => Ok(Some(Ar::Auto)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -79,18 +145,17 @@ fn plan_aspect_ratio(
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3FastResolution, ArtcraftRouterError> {
|
||||
use Veo3FastResolution as R;
|
||||
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(R::Default),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(R::SevenTwentyP),
|
||||
Some(RouterResolution::TenEightyP) => Ok(R::TenEightyP),
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", &format!("{:?}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(R::TenEightyP),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(R::SevenTwentyP),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::SevenTwentyP)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -98,18 +163,18 @@ fn plan_resolution(
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3FastDuration, ArtcraftRouterError> {
|
||||
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(Veo3FastDuration::Default),
|
||||
Some(4) => Ok(Veo3FastDuration::FourSeconds),
|
||||
Some(6) => Ok(Veo3FastDuration::SixSeconds),
|
||||
Some(8) => Ok(Veo3FastDuration::EightSeconds),
|
||||
None => Ok(None),
|
||||
Some(4) => Ok(Some(PlanDuration::FourSeconds)),
|
||||
Some(6) => Ok(Some(PlanDuration::SixSeconds)),
|
||||
Some(8) => Ok(Some(PlanDuration::EightSeconds)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo3FastDuration::EightSeconds),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo3FastDuration::FourSeconds),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::EightSeconds)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::FourSeconds)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -121,62 +186,100 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
})
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> Veo3FastTextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::FourSeconds => Veo3FastTextToVideoDuration::FourSeconds,
|
||||
PlanDuration::SixSeconds => Veo3FastTextToVideoDuration::SixSeconds,
|
||||
PlanDuration::EightSeconds => Veo3FastTextToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> Veo3FastImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::FourSeconds => Veo3FastImageToVideoDuration::FourSeconds,
|
||||
PlanDuration::SixSeconds => Veo3FastImageToVideoDuration::SixSeconds,
|
||||
PlanDuration::EightSeconds => Veo3FastImageToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: PlanResolution) -> Veo3FastTextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3FastTextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3FastTextToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: PlanResolution) -> Veo3FastImageToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3FastImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3FastImageToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
|
||||
..Default::default()
|
||||
mod mode_selection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_start_frame_picks_t2v() {
|
||||
let _ = t2v_request(base_t2v_builder());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let _ = i2v_request(base_i2v_builder());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_errors() {
|
||||
let mut b = base_i2v_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_veo_3_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_file_token_for_start_frame_errors() {
|
||||
let mut b = base_t2v_builder();
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string())));
|
||||
assert!(build_fal_veo_3_fast_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap(b: GenerateVideoRequestBuilder) -> Veo3FastRequest {
|
||||
match build_fal_veo_3_fast(b).expect("build") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3Fast(s)) => s.request,
|
||||
_ => panic!("expected FalVeo3Fast"),
|
||||
mod materialized_fields {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn i2v_prompt_passed_through() {
|
||||
let mut b = base_i2v_builder();
|
||||
b.prompt = Some("hello world".to_string());
|
||||
assert_eq!(i2v_request(b).prompt, "hello world");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = None;
|
||||
assert!(build_fal_veo_3_fast(b).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn i2v_image_url_passed_through() {
|
||||
assert_eq!(i2v_request(base_i2v_builder()).image_url, "https://example.com/a.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
|
||||
assert!(build_fal_veo_3_fast(b).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn audio_defaults_to_true() {
|
||||
assert_eq!(i2v_request(base_i2v_builder()).generate_audio, Some(true));
|
||||
assert_eq!(t2v_request(base_t2v_builder()).generate_audio, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let mut b = base_builder();
|
||||
b.prompt = Some("hello world".to_string());
|
||||
assert_eq!(unwrap(b).prompt, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_defaults_to_true() {
|
||||
assert!(unwrap(base_builder()).generate_audio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_false_passed_through() {
|
||||
let mut b = base_builder();
|
||||
b.generate_audio = Some(false);
|
||||
assert!(!unwrap(b).generate_audio);
|
||||
#[test]
|
||||
fn audio_false_passed_through() {
|
||||
let mut b = base_i2v_builder();
|
||||
b.generate_audio = Some(false);
|
||||
assert_eq!(i2v_request(b).generate_audio, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,17 +294,49 @@ mod tests {
|
||||
for &duration in &durations {
|
||||
for &aspect_ratio in &aspect_ratios {
|
||||
for &generate_audio in &audios {
|
||||
let mut b = base_builder();
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = duration;
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.generate_audio = generate_audio;
|
||||
assert!(build_fal_veo_3_fast(b).is_ok());
|
||||
combos += 1;
|
||||
for has_start_frame in [false, true] {
|
||||
let mut b = if has_start_frame { base_i2v_builder() } else { base_t2v_builder() };
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = duration;
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.generate_audio = generate_audio;
|
||||
assert!(build_fal_veo_3_fast_state(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(combos, 3 * 4 * 4 * 3);
|
||||
assert_eq!(combos, 3 * 4 * 4 * 3 * 2);
|
||||
}
|
||||
|
||||
fn base_t2v_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn base_i2v_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
|
||||
..base_t2v_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3FastTextToVideoRequest {
|
||||
match build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state").mode {
|
||||
FalVeo3FastMode::TextToVideo(r) => r,
|
||||
FalVeo3FastMode::ImageToVideo(_) => panic!("expected TextToVideo"),
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3FastImageToVideoRequest {
|
||||
match build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state").mode {
|
||||
FalVeo3FastMode::ImageToVideo(r) => r,
|
||||
FalVeo3FastMode::TextToVideo(_) => panic!("expected ImageToVideo"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-43
@@ -1,30 +1,31 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::Veo3FastDuration;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::veo_3_fast::request::FalVeo3FastRequestState;
|
||||
use crate::generate::generate_video::providers::fal::veo_3_fast::request::{
|
||||
FalVeo3FastMode, FalVeo3FastRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo3FastCostState {
|
||||
pub duration_seconds: u64,
|
||||
pub generate_audio: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalVeo3FastCostState {
|
||||
pub fn from_request(request: &FalVeo3FastRequestState) -> Self {
|
||||
Self {
|
||||
duration_seconds: duration_seconds_for_cost(request.request.duration),
|
||||
generate_audio: request.request.generate_audio,
|
||||
}
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalVeo3FastMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3FastMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// $0.10/sec audio off, $0.15/sec audio on (720p/1080p).
|
||||
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -34,33 +35,18 @@ impl FalVeo3FastCostState {
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_seconds_for_cost(d: Veo3FastDuration) -> u64 {
|
||||
match d {
|
||||
Veo3FastDuration::Default | Veo3FastDuration::EightSeconds => 8,
|
||||
Veo3FastDuration::SixSeconds => 6,
|
||||
Veo3FastDuration::FourSeconds => 4,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fal_veo_3_fast_state;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
|
||||
let b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
|
||||
duration_seconds,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
|
||||
}
|
||||
use super::*;
|
||||
|
||||
// Pricing (fal): $0.10/sec audio off, $0.15/sec audio on. Flat across
|
||||
// modalities; resolution (720p/1080p) does not change the price.
|
||||
|
||||
mod numeric_literal_pricing {
|
||||
use super::*;
|
||||
@@ -68,38 +54,62 @@ mod tests {
|
||||
#[test]
|
||||
fn audio_on_4s_is_60() {
|
||||
// $0.15/sec × 4s = 60¢.
|
||||
assert_eq!(cost_cents(Some(4), Some(true)), 60);
|
||||
assert_eq!(cost_cents(Some(4), Some(true), true), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true)), 90); }
|
||||
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true), true), 90); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
|
||||
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true), true), 120); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_4s_is_40() {
|
||||
// $0.10/sec × 4s = 40¢.
|
||||
assert_eq!(cost_cents(Some(4), Some(false)), 40);
|
||||
assert_eq!(cost_cents(Some(4), Some(false), true), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_8s_is_80() { assert_eq!(cost_cents(Some(8), Some(false)), 80); }
|
||||
fn audio_off_8s_is_80() { assert_eq!(cost_cents(Some(8), Some(false), true), 80); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_8s() {
|
||||
assert_eq!(cost_cents(None, Some(true)), 120);
|
||||
assert_eq!(cost_cents(None, Some(true), true), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_matches_i2v() {
|
||||
assert_eq!(cost_cents(Some(6), Some(true), false), cost_cents(Some(6), Some(true), true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_costs_more_than_no_audio() {
|
||||
assert!(cost_cents(Some(8), Some(false)) < cost_cents(Some(8), Some(true)));
|
||||
assert!(cost_cents(Some(8), Some(false), true) < cost_cents(Some(8), Some(true), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_duration_costs_more() {
|
||||
assert!(cost_cents(Some(4), Some(true)) < cost_cents(Some(6), Some(true)));
|
||||
assert!(cost_cents(Some(6), Some(true)) < cost_cents(Some(8), Some(true)));
|
||||
assert!(cost_cents(Some(4), Some(true), true) < cost_cents(Some(6), Some(true), true));
|
||||
assert!(cost_cents(Some(6), Some(true), true) < cost_cents(Some(8), Some(true), true));
|
||||
}
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, has_start_frame: bool) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
if has_start_frame {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
let state = build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state");
|
||||
FalVeo3FastCostState::from_request(&state)
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.expect("cost_in_usd_cents")
|
||||
}
|
||||
}
|
||||
|
||||
+92
-60
@@ -1,91 +1,123 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
|
||||
enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::image::veo_3_fast::api::Veo3FastImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::veo_3_fast::api::Veo3FastTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalVeo3FastMode {
|
||||
TextToVideo(Veo3FastTextToVideoRequest),
|
||||
ImageToVideo(Veo3FastImageToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo3FastRequestState {
|
||||
pub request: Veo3FastRequest,
|
||||
pub mode: FalVeo3FastMode,
|
||||
}
|
||||
|
||||
impl FalVeo3FastRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
|
||||
match &self.mode {
|
||||
FalVeo3FastMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3FastMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let args = Veo3FastArgs {
|
||||
request: self.request.clone(),
|
||||
api_key: &client.api_key,
|
||||
webhook_url,
|
||||
};
|
||||
// ── Helpers ──
|
||||
|
||||
let webhook_response = enqueue_veo_3_fast_image_to_video_webhook(args)
|
||||
.await
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::image::veo_3_fast::api::{
|
||||
Veo3FastImageToVideoDuration, Veo3FastImageToVideoResolution,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::test_helpers::get_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_image_to_video_720p_4s_audio() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog leaps into the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(4),
|
||||
generate_audio: Some(true),
|
||||
..builder()
|
||||
}).await;
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
fn builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_pipeline(b: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_fal_client();
|
||||
let dor = b.build2().expect("build2");
|
||||
let req = match dor {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request"),
|
||||
let state = FalVeo3FastRequestState {
|
||||
mode: FalVeo3FastMode::ImageToVideo(Veo3FastImageToVideoRequest {
|
||||
prompt: "the dog leaps into the lake.".to_string(),
|
||||
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
aspect_ratio: None,
|
||||
duration: Some(Veo3FastImageToVideoDuration::FourSeconds),
|
||||
resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP),
|
||||
generate_audio: Some(true),
|
||||
negative_prompt: None,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
};
|
||||
req.send_request(&client).await.expect("send")
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+593
-131
@@ -1,19 +1,29 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_first_last_frame_image_to_video_webhook::{
|
||||
EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio, EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds,
|
||||
EnqueueVeo3p1FirstLastFrameImageToVideoRequest, EnqueueVeo3p1FirstLastFrameImageToVideoResolution,
|
||||
use fal_client::requests::api::video::extend::veo_3p1::api::{
|
||||
Veo3p1ExtendVideoAspectRatio, Veo3p1ExtendVideoDuration, Veo3p1ExtendVideoRequest,
|
||||
Veo3p1ExtendVideoResolution,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::{
|
||||
EnqueueVeo3p1ImageToVideoAspectRatio, EnqueueVeo3p1ImageToVideoDurationSeconds,
|
||||
EnqueueVeo3p1ImageToVideoRequest, EnqueueVeo3p1ImageToVideoResolution,
|
||||
use fal_client::requests::api::video::image::veo_3p1::api::{
|
||||
Veo3p1ImageToVideoAspectRatio, Veo3p1ImageToVideoDuration, Veo3p1ImageToVideoRequest,
|
||||
Veo3p1ImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::{
|
||||
EnqueueVeo3p1TextToVideoAspectRatio, EnqueueVeo3p1TextToVideoDurationSeconds,
|
||||
EnqueueVeo3p1TextToVideoRequest, EnqueueVeo3p1TextToVideoResolution,
|
||||
use fal_client::requests::api::video::images::veo_3p1::api::{
|
||||
Veo3p1FirstLastFrameToVideoAspectRatio, Veo3p1FirstLastFrameToVideoDuration,
|
||||
Veo3p1FirstLastFrameToVideoRequest, Veo3p1FirstLastFrameToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::reference::veo_3p1::api::{
|
||||
Veo3p1ReferenceToVideoAspectRatio, Veo3p1ReferenceToVideoDuration, Veo3p1ReferenceToVideoRequest,
|
||||
Veo3p1ReferenceToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::text::veo_3p1::api::{
|
||||
Veo3p1TextToVideoAspectRatio, Veo3p1TextToVideoDuration, Veo3p1TextToVideoRequest,
|
||||
Veo3p1TextToVideoResolution,
|
||||
};
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
@@ -33,6 +43,7 @@ pub(crate) enum PlanAspectRatio {
|
||||
pub(crate) enum PlanResolution {
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
FourK,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -45,66 +56,143 @@ pub(crate) enum PlanDuration {
|
||||
pub fn build_fal_veo_3p1(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_veo_3p1_request_state(builder, "Veo 3.1")?;
|
||||
let state = build_fal_veo_3p1_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(state)))
|
||||
}
|
||||
|
||||
/// Shared builder used by both Veo 3.1 and Veo 3.1 Fast. The two variants take
|
||||
/// the exact same router-level inputs — only the downstream fal endpoint and
|
||||
/// pricing differ — so they share planning code.
|
||||
pub(crate) fn build_veo_3p1_request_state(
|
||||
pub(crate) fn build_fal_veo_3p1_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
model_label: &'static str,
|
||||
) -> Result<FalVeo3p1RequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start = optional_url(builder.start_frame.clone())?;
|
||||
let end = optional_url(builder.end_frame.clone())?;
|
||||
let reference_images = reference_image_urls(builder.reference_images.clone())?;
|
||||
let reference_videos = reference_video_urls(builder.reference_videos.clone())?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let negative_prompt = builder.negative_prompt.clone();
|
||||
let generate_audio = builder.generate_audio;
|
||||
|
||||
let mode = match (start, end) {
|
||||
(None, None) => FalVeo3p1Mode::TextToVideo(EnqueueVeo3p1TextToVideoRequest {
|
||||
prompt,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
generate_audio,
|
||||
enhance_prompt: None,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
}),
|
||||
(Some(image_url), None) => FalVeo3p1Mode::ImageToVideo(EnqueueVeo3p1ImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
|
||||
resolution: resolution.map(to_i2v_resolution),
|
||||
generate_audio,
|
||||
}),
|
||||
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1Mode::FirstLastFrame(
|
||||
EnqueueVeo3p1FirstLastFrameImageToVideoRequest {
|
||||
prompt,
|
||||
first_frame_url,
|
||||
last_frame_url,
|
||||
duration: duration.map(to_flf_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
|
||||
resolution: resolution.map(to_flf_resolution),
|
||||
generate_audio,
|
||||
},
|
||||
),
|
||||
(None, Some(_)) => {
|
||||
// Modality dispatch, most specific inputs first:
|
||||
// 1. reference videos → extend-video
|
||||
// 2. reference images → reference-to-video
|
||||
// 3. start + end frames → first-last-frame-to-video
|
||||
// 4. start frame only → image-to-video
|
||||
// 5. no media → text-to-video
|
||||
let mode = if let Some(video_urls) = reference_videos {
|
||||
if reference_images.is_some() {
|
||||
return Err(unsupported(
|
||||
"reference_images",
|
||||
"Veo 3.1 extend-video cannot combine reference_images with a reference video",
|
||||
));
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(
|
||||
"start_frame",
|
||||
"Veo 3.1 extend-video cannot combine start_frame with a reference video",
|
||||
));
|
||||
}
|
||||
if end.is_some() {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
&format!("{} requires a start_frame when end_frame is provided", model_label),
|
||||
"Veo 3.1 extend-video cannot combine end_frame with a reference video",
|
||||
));
|
||||
}
|
||||
if video_urls.len() != 1 {
|
||||
return Err(unsupported(
|
||||
"reference_videos",
|
||||
&format!("Veo 3.1 extend-video requires exactly 1 reference video, got {}", video_urls.len()),
|
||||
));
|
||||
}
|
||||
let video_url = video_urls.into_iter().next().expect("checked len == 1");
|
||||
FalVeo3p1Mode::ExtendVideo(Veo3p1ExtendVideoRequest {
|
||||
prompt,
|
||||
video_url,
|
||||
aspect_ratio: aspect_ratio.map(to_extend_aspect_ratio),
|
||||
duration: plan_extend_duration(builder.duration_seconds, strategy)?,
|
||||
resolution: resolution.map(|r| to_extend_resolution(r, strategy)).transpose()?,
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
} else if let Some(image_urls) = reference_images {
|
||||
if start.is_some() {
|
||||
return Err(unsupported(
|
||||
"start_frame",
|
||||
"Veo 3.1 reference-to-video cannot combine start_frame with reference_images",
|
||||
));
|
||||
}
|
||||
if end.is_some() {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Veo 3.1 reference-to-video cannot combine end_frame with reference_images",
|
||||
));
|
||||
}
|
||||
// NB: reference-to-video has no negative_prompt or seed fields on fal's
|
||||
// schema — those builder inputs are dropped silently.
|
||||
FalVeo3p1Mode::ReferenceToVideo(Veo3p1ReferenceToVideoRequest {
|
||||
prompt,
|
||||
image_urls,
|
||||
aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio),
|
||||
duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration),
|
||||
resolution: resolution.map(to_reference_resolution),
|
||||
generate_audio,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
} else {
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
match (start, end) {
|
||||
(None, None) => FalVeo3p1Mode::TextToVideo(Veo3p1TextToVideoRequest {
|
||||
prompt,
|
||||
aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio),
|
||||
duration: duration.map(to_t2v_duration),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
(Some(image_url), None) => FalVeo3p1Mode::ImageToVideo(Veo3p1ImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
|
||||
duration: duration.map(to_i2v_duration),
|
||||
resolution: resolution.map(to_i2v_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1Mode::FirstLastFrameToVideo(
|
||||
Veo3p1FirstLastFrameToVideoRequest {
|
||||
prompt,
|
||||
first_frame_url,
|
||||
last_frame_url,
|
||||
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
|
||||
duration: duration.map(to_flf_duration),
|
||||
resolution: resolution.map(to_flf_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
},
|
||||
),
|
||||
(None, Some(_)) => {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Veo 3.1 requires a start_frame when end_frame is provided",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FalVeo3p1RequestState { mode })
|
||||
@@ -120,6 +208,30 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
|
||||
}
|
||||
}
|
||||
|
||||
fn reference_image_urls(refs: Option<ImageListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match refs {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reference_video_urls(refs: Option<VideoListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match refs {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) if urls.is_empty() => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
|
||||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -153,6 +265,7 @@ fn plan_resolution(
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)),
|
||||
Some(RouterResolution::FourK) => Ok(Some(R::FourK)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", &format!("{:?}", other)))
|
||||
@@ -176,8 +289,43 @@ fn plan_duration(
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Eight)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Four)),
|
||||
// Nearest supported duration above/below, clamped to the 4s–8s range.
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
|
||||
0..=4 => PlanDuration::Four,
|
||||
5..=6 => PlanDuration::Six,
|
||||
_ => PlanDuration::Eight,
|
||||
})),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
|
||||
0..=5 => PlanDuration::Four,
|
||||
6..=7 => PlanDuration::Six,
|
||||
_ => PlanDuration::Eight,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend-video also supports fal's 7s default in addition to 4/6/8.
|
||||
fn plan_extend_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Veo3p1ExtendVideoDuration>, ArtcraftRouterError> {
|
||||
use Veo3p1ExtendVideoDuration as D;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(4) => Ok(Some(D::FourSeconds)),
|
||||
Some(6) => Ok(Some(D::SixSeconds)),
|
||||
Some(7) => Ok(Some(D::SevenSeconds)),
|
||||
Some(8) => Ok(Some(D::EightSeconds)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds }))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds }))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -189,98 +337,145 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
})
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> EnqueueVeo3p1TextToVideoDurationSeconds {
|
||||
match d {
|
||||
PlanDuration::Four => EnqueueVeo3p1TextToVideoDurationSeconds::Four,
|
||||
PlanDuration::Six => EnqueueVeo3p1TextToVideoDurationSeconds::Six,
|
||||
PlanDuration::Eight => EnqueueVeo3p1TextToVideoDurationSeconds::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1TextToVideoAspectRatio {
|
||||
// The text-to-video endpoint has no `auto` aspect ratio; omit the field and
|
||||
// let fal apply its own default (16:9).
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1TextToVideoAspectRatio> {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => EnqueueVeo3p1TextToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1TextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1TextToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::Auto => None,
|
||||
PlanAspectRatio::SixteenByNine => Some(Veo3p1TextToVideoAspectRatio::SixteenByNine),
|
||||
PlanAspectRatio::NineBySixteen => Some(Veo3p1TextToVideoAspectRatio::NineBySixteen),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: PlanResolution) -> EnqueueVeo3p1TextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => EnqueueVeo3p1TextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => EnqueueVeo3p1TextToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> EnqueueVeo3p1ImageToVideoDurationSeconds {
|
||||
fn to_t2v_duration(d: PlanDuration) -> Veo3p1TextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => EnqueueVeo3p1ImageToVideoDurationSeconds::Four,
|
||||
PlanDuration::Six => EnqueueVeo3p1ImageToVideoDurationSeconds::Six,
|
||||
PlanDuration::Eight => EnqueueVeo3p1ImageToVideoDurationSeconds::Eight,
|
||||
PlanDuration::Four => Veo3p1TextToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1TextToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1TextToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1ImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => EnqueueVeo3p1ImageToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1ImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1ImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: PlanResolution) -> EnqueueVeo3p1ImageToVideoResolution {
|
||||
fn to_t2v_resolution(r: PlanResolution) -> Veo3p1TextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => EnqueueVeo3p1ImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => EnqueueVeo3p1ImageToVideoResolution::TenEightyP,
|
||||
PlanResolution::SevenTwentyP => Veo3p1TextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1TextToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1TextToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_duration(d: PlanDuration) -> EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds {
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1ImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1ImageToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1ImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1ImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> Veo3p1ImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Four,
|
||||
PlanDuration::Six => EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Six,
|
||||
PlanDuration::Eight => EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight,
|
||||
PlanDuration::Four => Veo3p1ImageToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1ImageToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1ImageToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_resolution(r: PlanResolution) -> EnqueueVeo3p1FirstLastFrameImageToVideoResolution {
|
||||
fn to_i2v_resolution(r: PlanResolution) -> Veo3p1ImageToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => EnqueueVeo3p1FirstLastFrameImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => EnqueueVeo3p1FirstLastFrameImageToVideoResolution::TenEightyP,
|
||||
PlanResolution::SevenTwentyP => Veo3p1ImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1ImageToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1ImageToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FirstLastFrameToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1FirstLastFrameToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1FirstLastFrameToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_duration(d: PlanDuration) -> Veo3p1FirstLastFrameToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => Veo3p1FirstLastFrameToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1FirstLastFrameToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1FirstLastFrameToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_resolution(r: PlanResolution) -> Veo3p1FirstLastFrameToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1FirstLastFrameToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1FirstLastFrameToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
// The reference-to-video endpoint has no `auto` aspect ratio; omit the field
|
||||
// and let fal apply its own default (16:9).
|
||||
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1ReferenceToVideoAspectRatio> {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => None,
|
||||
PlanAspectRatio::SixteenByNine => Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine),
|
||||
PlanAspectRatio::NineBySixteen => Some(Veo3p1ReferenceToVideoAspectRatio::NineBySixteen),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_reference_duration(d: PlanDuration) -> Veo3p1ReferenceToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => Veo3p1ReferenceToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1ReferenceToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1ReferenceToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_reference_resolution(r: PlanResolution) -> Veo3p1ReferenceToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3p1ReferenceToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1ReferenceToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1ReferenceToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_extend_aspect_ratio(a: PlanAspectRatio) -> Veo3p1ExtendVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1ExtendVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1ExtendVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1ExtendVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
// Extend-video has no 4k tier; the nearest supported resolution in either
|
||||
// direction is 1080p.
|
||||
fn to_extend_resolution(
|
||||
r: PlanResolution,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3p1ExtendVideoResolution, ArtcraftRouterError> {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Ok(Veo3p1ExtendVideoResolution::SevenTwentyP),
|
||||
PlanResolution::TenEightyP => Ok(Veo3p1ExtendVideoResolution::TenEightyP),
|
||||
PlanResolution::FourK => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", "Veo 3.1 extend-video does not support 4k"))
|
||||
}
|
||||
_ => Ok(Veo3p1ExtendVideoResolution::TenEightyP),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1Mode {
|
||||
match build_fal_veo_3p1(b).expect("build") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(s)) => s.mode,
|
||||
_ => panic!("expected FalVeo3p1"),
|
||||
}
|
||||
}
|
||||
const START_FRAME_URL: &str = "https://example.com/a.png";
|
||||
const END_FRAME_URL: &str = "https://example.com/b.png";
|
||||
const REFERENCE_IMAGE_URL_A: &str = "https://example.com/ref_a.png";
|
||||
const REFERENCE_IMAGE_URL_B: &str = "https://example.com/ref_b.png";
|
||||
const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4";
|
||||
|
||||
mod mode_selection {
|
||||
use super::*;
|
||||
@@ -293,23 +488,211 @@ mod tests {
|
||||
#[test]
|
||||
fn start_only_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_picks_first_last_frame() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::FirstLastFrame(_)));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::FirstLastFrameToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_only_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
assert!(build_fal_veo_3p1(b).is_err());
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_pick_reference_to_video() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ReferenceToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_pick_extend_video() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ExtendVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_reference_lists_fall_through_to_t2v() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![]));
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![]));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_build_fn_wraps_request_enum() {
|
||||
match build_fal_veo_3p1(base_builder()).expect("build") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(s)) => {
|
||||
assert!(matches!(s.mode, FalVeo3p1Mode::TextToVideo(_)));
|
||||
}
|
||||
_ => panic!("expected Request(FalVeo3p1)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod extend_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extend_maps_video_url_and_7s_duration() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.duration_seconds = Some(7);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::ExtendVideo(r) => {
|
||||
assert_eq!(r.video_url, REFERENCE_VIDEO_URL);
|
||||
assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SevenSeconds)));
|
||||
}
|
||||
_ => panic!("expected extend"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_requires_exactly_one_video() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![
|
||||
REFERENCE_VIDEO_URL.to_string(),
|
||||
"https://example.com/other.mp4".to_string(),
|
||||
]));
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_rejects_start_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_rejects_end_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_rejects_reference_images() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_duration_5s_pay_more_upgrades_to_6() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::ExtendVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SixSeconds)));
|
||||
}
|
||||
_ => panic!("expected extend"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_4k_errors_with_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_4k_maps_to_1080p_with_mitigation() {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = strategy;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::ExtendVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(Veo3p1ExtendVideoResolution::TenEightyP)));
|
||||
}
|
||||
_ => panic!("expected extend"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod reference_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_maps_image_urls() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![
|
||||
REFERENCE_IMAGE_URL_A.to_string(),
|
||||
REFERENCE_IMAGE_URL_B.to_string(),
|
||||
]));
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::ReferenceToVideo(r) => {
|
||||
assert_eq!(r.image_urls, vec![REFERENCE_IMAGE_URL_A, REFERENCE_IMAGE_URL_B]);
|
||||
}
|
||||
_ => panic!("expected reference"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_rejects_start_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_rejects_end_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_auto_aspect_ratio_is_omitted() {
|
||||
// reference-to-video has no `auto` wire value; Auto must map to None.
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::ReferenceToVideo(r) => assert!(r.aspect_ratio.is_none()),
|
||||
_ => panic!("expected reference"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_maps_duration_and_resolution() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
|
||||
b.duration_seconds = Some(6);
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::ReferenceToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1ReferenceToVideoDuration::SixSeconds)));
|
||||
assert!(matches!(r.resolution, Some(Veo3p1ReferenceToVideoResolution::FourK)));
|
||||
}
|
||||
_ => panic!("expected reference"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +705,7 @@ mod tests {
|
||||
b.duration_seconds = Some(4);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::TextToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(EnqueueVeo3p1TextToVideoDurationSeconds::Four)));
|
||||
assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::FourSeconds)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
@@ -333,7 +716,33 @@ mod tests {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_3p1(b).is_err());
|
||||
assert!(build_fal_veo_3p1_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5s_pay_more_upgrades_to_6() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::TextToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::SixSeconds)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5s_pay_less_downgrades_to_4() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::TextToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::FourSeconds)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,16 +755,56 @@ mod tests {
|
||||
b.resolution = Some(RouterResolution::SevenTwentyP);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::TextToVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(EnqueueVeo3p1TextToVideoResolution::SevenTwentyP)));
|
||||
assert!(matches!(r.resolution, Some(Veo3p1TextToVideoResolution::SevenTwentyP)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_4k_maps_to_four_k() {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::TextToVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(Veo3p1TextToVideoResolution::FourK)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn t2v_auto_aspect_ratio_is_omitted() {
|
||||
// t2v has no `auto` wire value; Auto must map to None.
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::TextToVideo(r) => assert!(r.aspect_ratio.is_none()),
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_auto_aspect_ratio_maps_to_auto() {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1Mode::ImageToVideo(r) => {
|
||||
assert!(matches!(r.aspect_ratio, Some(Veo3p1ImageToVideoAspectRatio::Auto)));
|
||||
}
|
||||
_ => panic!("expected i2v"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_combinatorial_pass() {
|
||||
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)];
|
||||
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), Some(RouterResolution::FourK)];
|
||||
let durations = [None, Some(4u16), Some(6), Some(8)];
|
||||
let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)];
|
||||
let audios = [None, Some(true), Some(false)];
|
||||
@@ -372,18 +821,31 @@ mod tests {
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.generate_audio = generate_audio;
|
||||
if frames >= 1 {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
}
|
||||
if frames == 2 {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
}
|
||||
assert!(build_fal_veo_3p1(b).is_ok());
|
||||
assert!(build_fal_veo_3p1_state(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(combos, 3 * 4 * 4 * 3 * 3);
|
||||
assert_eq!(combos, 4 * 4 * 4 * 3 * 3);
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1Mode {
|
||||
build_fal_veo_3p1_state(b).expect("build").mode
|
||||
}
|
||||
}
|
||||
|
||||
+196
-97
@@ -1,30 +1,32 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_first_last_frame_image_to_video_webhook::EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds;
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::EnqueueVeo3p1ImageToVideoDurationSeconds;
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::EnqueueVeo3p1TextToVideoDurationSeconds;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1::request::{FalVeo3p1Mode, FalVeo3p1RequestState};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo3p1CostState {
|
||||
pub duration_seconds: u64,
|
||||
pub generate_audio: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalVeo3p1CostState {
|
||||
pub fn from_request(request: &FalVeo3p1RequestState) -> Self {
|
||||
let (duration_seconds, generate_audio) = extract_duration_and_audio(&request.mode);
|
||||
Self { duration_seconds, generate_audio }
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalVeo3p1Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1Mode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1Mode::ExtendVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// $0.20/sec audio off, $0.40/sec audio on (720p/1080p).
|
||||
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -34,107 +36,204 @@ impl FalVeo3p1CostState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_duration_and_audio(mode: &FalVeo3p1Mode) -> (u64, bool) {
|
||||
match mode {
|
||||
FalVeo3p1Mode::TextToVideo(req) => (t2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
|
||||
FalVeo3p1Mode::ImageToVideo(req) => (i2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
|
||||
FalVeo3p1Mode::FirstLastFrame(req) => (flf_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_duration_seconds(d: Option<EnqueueVeo3p1TextToVideoDurationSeconds>) -> u64 {
|
||||
match d {
|
||||
Some(EnqueueVeo3p1TextToVideoDurationSeconds::Four) => 4,
|
||||
Some(EnqueueVeo3p1TextToVideoDurationSeconds::Six) => 6,
|
||||
Some(EnqueueVeo3p1TextToVideoDurationSeconds::Eight) | None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_duration_seconds(d: Option<EnqueueVeo3p1ImageToVideoDurationSeconds>) -> u64 {
|
||||
match d {
|
||||
Some(EnqueueVeo3p1ImageToVideoDurationSeconds::Four) => 4,
|
||||
Some(EnqueueVeo3p1ImageToVideoDurationSeconds::Six) => 6,
|
||||
Some(EnqueueVeo3p1ImageToVideoDurationSeconds::Eight) | None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn flf_duration_seconds(d: Option<EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds>) -> u64 {
|
||||
match d {
|
||||
Some(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Four) => 4,
|
||||
Some(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Six) => 6,
|
||||
Some(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight) | None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1_state;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Veo 3.1 pricing:
|
||||
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
|
||||
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
|
||||
// Extend-video has no 4k tier and defaults to 7s.
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Modality {
|
||||
TextToVideo,
|
||||
ImageToVideo,
|
||||
FirstLastFrame,
|
||||
Reference,
|
||||
Extend,
|
||||
}
|
||||
|
||||
mod text_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_on_720p_is_320() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)), 320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_off_720p_is_160() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(false)), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_4s_audio_on_is_160() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(true)), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_6s_audio_on_is_240() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(6), None, Some(true)), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_on_4k_is_480() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(true)), 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_off_4k_is_320() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(false)), 320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_defaults_are_8s_720p_audio_on_320() {
|
||||
// duration=None→8s, resolution=None→720p, audio=None→on.
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, None, None, None), 320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_1080p_bills_same_as_720p() {
|
||||
assert_eq!(
|
||||
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true)),
|
||||
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod image_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn i2v_6s_audio_on_is_240() {
|
||||
assert_eq!(cost_cents(Modality::ImageToVideo, Some(6), None, Some(true)), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_8s_audio_off_1080p_is_160() {
|
||||
assert_eq!(cost_cents(Modality::ImageToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(false)), 160);
|
||||
}
|
||||
}
|
||||
|
||||
mod first_last_frame_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flf_4s_audio_on_is_160() {
|
||||
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(4), None, Some(true)), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flf_8s_audio_on_4k_is_480() {
|
||||
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(8), Some(RouterResolution::FourK), Some(true)), 480);
|
||||
}
|
||||
}
|
||||
|
||||
mod reference_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_8s_audio_on_1080p_is_320() {
|
||||
assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::TenEightyP), Some(true)), 320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_4s_audio_off_720p_is_80() {
|
||||
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_8s_audio_on_4k_is_480() {
|
||||
assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::FourK), Some(true)), 480);
|
||||
}
|
||||
}
|
||||
|
||||
mod extend_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extend_7s_audio_on_is_280() {
|
||||
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(true)), 280);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_7s_audio_off_is_140() {
|
||||
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(false)), 140);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_8s_audio_on_is_320() {
|
||||
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_defaults_are_7s_audio_on_280() {
|
||||
// duration=None→7s (fal's extend default), audio=None→on.
|
||||
assert_eq!(cost_cents(Modality::Extend, None, None, None), 280);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_i2v_flf_and_reference_price_identically() {
|
||||
let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true));
|
||||
for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] {
|
||||
assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_costs_more_than_no_audio() {
|
||||
assert!(
|
||||
cost_cents(Modality::TextToVideo, Some(8), None, Some(false))
|
||||
< cost_cents(Modality::TextToVideo, Some(8), None, Some(true))
|
||||
);
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
modality: Modality,
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
generate_audio: Option<bool>,
|
||||
frames: u8,
|
||||
) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
if frames >= 1 {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
match modality {
|
||||
Modality::TextToVideo => {}
|
||||
Modality::ImageToVideo => {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
Modality::FirstLastFrame => {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
}
|
||||
Modality::Reference => {
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
|
||||
}
|
||||
Modality::Extend => {
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/in.mp4".to_string()]));
|
||||
}
|
||||
}
|
||||
if frames == 2 {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
}
|
||||
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
|
||||
}
|
||||
|
||||
mod numeric_literal_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn t2v_audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true), 0), 160); }
|
||||
|
||||
#[test]
|
||||
fn t2v_audio_on_6s_is_240() { assert_eq!(cost_cents(Some(6), Some(true), 0), 240); }
|
||||
|
||||
#[test]
|
||||
fn t2v_audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true), 0), 320); }
|
||||
|
||||
#[test]
|
||||
fn t2v_audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false), 0), 80); }
|
||||
|
||||
#[test]
|
||||
fn t2v_audio_off_8s_is_160() { assert_eq!(cost_cents(Some(8), Some(false), 0), 160); }
|
||||
|
||||
#[test]
|
||||
fn t2v_no_duration_defaults_to_8s() {
|
||||
assert_eq!(cost_cents(None, Some(true), 0), 320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_no_audio_defaults_to_audio_on() {
|
||||
assert_eq!(cost_cents(Some(6), None, 0), 240);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_modes_price_identically() {
|
||||
let t2v = cost_cents(Some(6), Some(true), 0);
|
||||
let i2v = cost_cents(Some(6), Some(true), 1);
|
||||
let flf = cost_cents(Some(6), Some(true), 2);
|
||||
assert_eq!(t2v, i2v);
|
||||
assert_eq!(i2v, flf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_costs_more_than_no_audio() {
|
||||
assert!(cost_cents(Some(8), Some(false), 0) < cost_cents(Some(8), Some(true), 0));
|
||||
let state = build_fal_veo_3p1_state(b).expect("build state");
|
||||
FalVeo3p1CostState::from_request(&state)
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.expect("cost")
|
||||
}
|
||||
}
|
||||
|
||||
+97
-60
@@ -1,30 +1,26 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_first_last_frame_image_to_video_webhook::{
|
||||
enqueue_veo_3p1_first_last_frame_image_to_video_webhook, EnqueueVeo3p1FirstLastFrameImageToVideoArgs,
|
||||
EnqueueVeo3p1FirstLastFrameImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::{
|
||||
enqueue_veo_3p1_image_to_video_webhook, EnqueueVeo3p1ImageToVideoArgs, EnqueueVeo3p1ImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::{
|
||||
enqueue_veo_3p1_text_to_video_webhook, EnqueueVeo3p1TextToVideoArgs, EnqueueVeo3p1TextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::extend::veo_3p1::api::Veo3p1ExtendVideoRequest;
|
||||
use fal_client::requests::api::video::image::veo_3p1::api::Veo3p1ImageToVideoRequest;
|
||||
use fal_client::requests::api::video::images::veo_3p1::api::Veo3p1FirstLastFrameToVideoRequest;
|
||||
use fal_client::requests::api::video::reference::veo_3p1::api::Veo3p1ReferenceToVideoRequest;
|
||||
use fal_client::requests::api::video::text::veo_3p1::api::Veo3p1TextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalVeo3p1Mode {
|
||||
TextToVideo(EnqueueVeo3p1TextToVideoRequest),
|
||||
ImageToVideo(EnqueueVeo3p1ImageToVideoRequest),
|
||||
FirstLastFrame(EnqueueVeo3p1FirstLastFrameImageToVideoRequest),
|
||||
TextToVideo(Veo3p1TextToVideoRequest),
|
||||
ImageToVideo(Veo3p1ImageToVideoRequest),
|
||||
FirstLastFrameToVideo(Veo3p1FirstLastFrameToVideoRequest),
|
||||
ReferenceToVideo(Veo3p1ReferenceToVideoRequest),
|
||||
ExtendVideo(Veo3p1ExtendVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -34,66 +30,81 @@ pub struct FalVeo3p1RequestState {
|
||||
|
||||
impl FalVeo3p1RequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
|
||||
FalVeo3p1Mode::TextToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueVeo3p1TextToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_veo_3p1_text_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalVeo3p1Mode::ImageToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueVeo3p1ImageToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_veo_3p1_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalVeo3p1Mode::FirstLastFrame(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueVeo3p1FirstLastFrameImageToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_veo_3p1_first_last_frame_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
};
|
||||
match &self.mode {
|
||||
FalVeo3p1Mode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1Mode::ImageToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1Mode::FirstLastFrameToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1Mode::ReferenceToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1Mode::ExtendVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let webhook_response = webhook_response
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
// ── Helpers ──
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::test_helpers::get_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_text_to_video_720p_4s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("a quiet meadow with wildflowers".to_string()),
|
||||
@@ -105,7 +116,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_image_to_video_8s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog leaps into the lake.".to_string()),
|
||||
@@ -117,7 +128,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_first_last_frame() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("a smooth transition between scenes".to_string()),
|
||||
@@ -129,6 +140,32 @@ mod tests {
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_reference_to_video_4s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog runs across a sunlit meadow".to_string()),
|
||||
reference_images: Some(ImageListRef::Urls(vec![JUNO_AT_LAKE_IMAGE_URL.to_string()])),
|
||||
duration_seconds: Some(4),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
}).await;
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_extend_video_7s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the scene continues naturally, keeping the same motion and style".to_string()),
|
||||
reference_videos: Some(VideoListRef::Urls(vec![ANGRY_SHIBA_VIDEO_URL.to_string()])),
|
||||
duration_seconds: Some(7),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
}).await;
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
fn builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1,
|
||||
|
||||
+542
-134
@@ -1,19 +1,29 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook::{
|
||||
EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio, EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds,
|
||||
EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest, EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution,
|
||||
use fal_client::requests::api::video::extend::veo_3p1_fast::api::{
|
||||
Veo3p1FastExtendVideoAspectRatio, Veo3p1FastExtendVideoDuration, Veo3p1FastExtendVideoRequest,
|
||||
Veo3p1FastExtendVideoResolution,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::{
|
||||
EnqueueVeo3p1FastImageToVideoAspectRatio, EnqueueVeo3p1FastImageToVideoDurationSeconds,
|
||||
EnqueueVeo3p1FastImageToVideoRequest, EnqueueVeo3p1FastImageToVideoResolution,
|
||||
use fal_client::requests::api::video::image::veo_3p1_fast::api::{
|
||||
Veo3p1FastImageToVideoAspectRatio, Veo3p1FastImageToVideoDuration, Veo3p1FastImageToVideoRequest,
|
||||
Veo3p1FastImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::{
|
||||
EnqueueVeo3p1FastTextToVideoAspectRatio, EnqueueVeo3p1FastTextToVideoDurationSeconds,
|
||||
EnqueueVeo3p1FastTextToVideoRequest, EnqueueVeo3p1FastTextToVideoResolution,
|
||||
use fal_client::requests::api::video::images::veo_3p1_fast::api::{
|
||||
Veo3p1FastFirstLastFrameToVideoAspectRatio, Veo3p1FastFirstLastFrameToVideoDuration,
|
||||
Veo3p1FastFirstLastFrameToVideoRequest, Veo3p1FastFirstLastFrameToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::reference::veo_3p1_fast::api::{
|
||||
Veo3p1FastReferenceToVideoAspectRatio, Veo3p1FastReferenceToVideoDuration,
|
||||
Veo3p1FastReferenceToVideoRequest, Veo3p1FastReferenceToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::text::veo_3p1_fast::api::{
|
||||
Veo3p1FastTextToVideoAspectRatio, Veo3p1FastTextToVideoDuration, Veo3p1FastTextToVideoRequest,
|
||||
Veo3p1FastTextToVideoResolution,
|
||||
};
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
@@ -35,6 +45,7 @@ pub(crate) enum PlanAspectRatio {
|
||||
pub(crate) enum PlanResolution {
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
FourK,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -47,60 +58,146 @@ pub(crate) enum PlanDuration {
|
||||
pub fn build_fal_veo_3p1_fast(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_veo_3p1_fast_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_veo_3p1_fast_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalVeo3p1FastRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let start = optional_url(builder.start_frame.clone())?;
|
||||
let end = optional_url(builder.end_frame.clone())?;
|
||||
let reference_images = reference_image_urls(builder.reference_images.clone())?;
|
||||
let reference_videos = reference_video_urls(builder.reference_videos.clone())?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let negative_prompt = builder.negative_prompt.clone();
|
||||
let generate_audio = builder.generate_audio;
|
||||
|
||||
let mode = match (start, end) {
|
||||
(None, None) => FalVeo3p1FastMode::TextToVideo(EnqueueVeo3p1FastTextToVideoRequest {
|
||||
prompt,
|
||||
duration: duration.map(to_t2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
generate_audio,
|
||||
enhance_prompt: None,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
}),
|
||||
(Some(image_url), None) => FalVeo3p1FastMode::ImageToVideo(EnqueueVeo3p1FastImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
duration: duration.map(to_i2v_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
|
||||
resolution: resolution.map(to_i2v_resolution),
|
||||
generate_audio,
|
||||
}),
|
||||
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1FastMode::FirstLastFrame(
|
||||
EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest {
|
||||
prompt,
|
||||
first_frame_url,
|
||||
last_frame_url,
|
||||
duration: duration.map(to_flf_duration),
|
||||
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
|
||||
resolution: resolution.map(to_flf_resolution),
|
||||
generate_audio,
|
||||
},
|
||||
),
|
||||
(None, Some(_)) => {
|
||||
// Modality dispatch, most specific inputs first:
|
||||
// 1. reference videos → extend-video
|
||||
// 2. reference images → reference-to-video
|
||||
// 3. start + end frames → first-last-frame-to-video
|
||||
// 4. start frame only → image-to-video
|
||||
// 5. no media → text-to-video
|
||||
let mode = if let Some(video_urls) = reference_videos {
|
||||
if reference_images.is_some() {
|
||||
return Err(unsupported(
|
||||
"reference_images",
|
||||
"Veo 3.1 Fast extend-video cannot combine reference_images with a reference video",
|
||||
));
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(
|
||||
"start_frame",
|
||||
"Veo 3.1 Fast extend-video cannot combine start_frame with a reference video",
|
||||
));
|
||||
}
|
||||
if end.is_some() {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Veo 3.1 Fast requires a start_frame when end_frame is provided",
|
||||
"Veo 3.1 Fast extend-video cannot combine end_frame with a reference video",
|
||||
));
|
||||
}
|
||||
if video_urls.len() != 1 {
|
||||
return Err(unsupported(
|
||||
"reference_videos",
|
||||
&format!("Veo 3.1 Fast extend-video requires exactly 1 reference video, got {}", video_urls.len()),
|
||||
));
|
||||
}
|
||||
let video_url = video_urls.into_iter().next().expect("checked len == 1");
|
||||
FalVeo3p1FastMode::ExtendVideo(Veo3p1FastExtendVideoRequest {
|
||||
prompt,
|
||||
video_url,
|
||||
aspect_ratio: aspect_ratio.map(to_extend_aspect_ratio),
|
||||
duration: plan_extend_duration(builder.duration_seconds, strategy)?,
|
||||
resolution: resolution.map(|r| to_extend_resolution(r, strategy)).transpose()?,
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
} else if let Some(image_urls) = reference_images {
|
||||
if start.is_some() {
|
||||
return Err(unsupported(
|
||||
"start_frame",
|
||||
"Veo 3.1 Fast reference-to-video cannot combine start_frame with reference_images",
|
||||
));
|
||||
}
|
||||
if end.is_some() {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Veo 3.1 Fast reference-to-video cannot combine end_frame with reference_images",
|
||||
));
|
||||
}
|
||||
// NB: reference-to-video has no negative_prompt or seed fields on fal's
|
||||
// schema — those builder inputs are dropped silently.
|
||||
FalVeo3p1FastMode::ReferenceToVideo(Veo3p1FastReferenceToVideoRequest {
|
||||
prompt,
|
||||
image_urls,
|
||||
aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio),
|
||||
duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration),
|
||||
resolution: resolution.map(to_reference_resolution),
|
||||
generate_audio,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
})
|
||||
} else {
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
match (start, end) {
|
||||
(None, None) => FalVeo3p1FastMode::TextToVideo(Veo3p1FastTextToVideoRequest {
|
||||
prompt,
|
||||
aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio),
|
||||
duration: duration.map(to_t2v_duration),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
(Some(image_url), None) => FalVeo3p1FastMode::ImageToVideo(Veo3p1FastImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
|
||||
duration: duration.map(to_i2v_duration),
|
||||
resolution: resolution.map(to_i2v_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1FastMode::FirstLastFrameToVideo(
|
||||
Veo3p1FastFirstLastFrameToVideoRequest {
|
||||
prompt,
|
||||
first_frame_url,
|
||||
last_frame_url,
|
||||
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
|
||||
duration: duration.map(to_flf_duration),
|
||||
resolution: resolution.map(to_flf_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
},
|
||||
),
|
||||
(None, Some(_)) => {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Veo 3.1 Fast requires a start_frame when end_frame is provided",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(
|
||||
FalVeo3p1FastRequestState { mode },
|
||||
)))
|
||||
Ok(FalVeo3p1FastRequestState { mode })
|
||||
}
|
||||
|
||||
fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
@@ -113,6 +210,30 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
|
||||
}
|
||||
}
|
||||
|
||||
fn reference_image_urls(refs: Option<ImageListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match refs {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reference_video_urls(refs: Option<VideoListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match refs {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) if urls.is_empty() => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
|
||||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -146,6 +267,7 @@ fn plan_resolution(
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)),
|
||||
Some(RouterResolution::FourK) => Ok(Some(R::FourK)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", &format!("{:?}", other)))
|
||||
@@ -169,8 +291,43 @@ fn plan_duration(
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Eight)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Four)),
|
||||
// Nearest supported duration above/below, clamped to the 4s–8s range.
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
|
||||
0..=4 => PlanDuration::Four,
|
||||
5..=6 => PlanDuration::Six,
|
||||
_ => PlanDuration::Eight,
|
||||
})),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
|
||||
0..=5 => PlanDuration::Four,
|
||||
6..=7 => PlanDuration::Six,
|
||||
_ => PlanDuration::Eight,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend-video also supports fal's 7s default in addition to 4/6/8.
|
||||
fn plan_extend_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Veo3p1FastExtendVideoDuration>, ArtcraftRouterError> {
|
||||
use Veo3p1FastExtendVideoDuration as D;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(4) => Ok(Some(D::FourSeconds)),
|
||||
Some(6) => Ok(Some(D::SixSeconds)),
|
||||
Some(7) => Ok(Some(D::SevenSeconds)),
|
||||
Some(8) => Ok(Some(D::EightSeconds)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds }))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds }))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -182,129 +339,367 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
})
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> EnqueueVeo3p1FastTextToVideoDurationSeconds {
|
||||
match d {
|
||||
PlanDuration::Four => EnqueueVeo3p1FastTextToVideoDurationSeconds::Four,
|
||||
PlanDuration::Six => EnqueueVeo3p1FastTextToVideoDurationSeconds::Six,
|
||||
PlanDuration::Eight => EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FastTextToVideoAspectRatio {
|
||||
// The text-to-video endpoint has no `auto` aspect ratio; omit the field and
|
||||
// let fal apply its own default (16:9).
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1FastTextToVideoAspectRatio> {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => EnqueueVeo3p1FastTextToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FastTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FastTextToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::Auto => None,
|
||||
PlanAspectRatio::SixteenByNine => Some(Veo3p1FastTextToVideoAspectRatio::SixteenByNine),
|
||||
PlanAspectRatio::NineBySixteen => Some(Veo3p1FastTextToVideoAspectRatio::NineBySixteen),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: PlanResolution) -> EnqueueVeo3p1FastTextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => EnqueueVeo3p1FastTextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => EnqueueVeo3p1FastTextToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> EnqueueVeo3p1FastImageToVideoDurationSeconds {
|
||||
fn to_t2v_duration(d: PlanDuration) -> Veo3p1FastTextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => EnqueueVeo3p1FastImageToVideoDurationSeconds::Four,
|
||||
PlanDuration::Six => EnqueueVeo3p1FastImageToVideoDurationSeconds::Six,
|
||||
PlanDuration::Eight => EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight,
|
||||
PlanDuration::Four => Veo3p1FastTextToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1FastTextToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1FastTextToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FastImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => EnqueueVeo3p1FastImageToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FastImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FastImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: PlanResolution) -> EnqueueVeo3p1FastImageToVideoResolution {
|
||||
fn to_t2v_resolution(r: PlanResolution) -> Veo3p1FastTextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => EnqueueVeo3p1FastImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => EnqueueVeo3p1FastImageToVideoResolution::TenEightyP,
|
||||
PlanResolution::SevenTwentyP => Veo3p1FastTextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1FastTextToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1FastTextToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_duration(d: PlanDuration) -> EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds {
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1FastImageToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1FastImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1FastImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> Veo3p1FastImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Four,
|
||||
PlanDuration::Six => EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six,
|
||||
PlanDuration::Eight => EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight,
|
||||
PlanDuration::Four => Veo3p1FastImageToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1FastImageToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1FastImageToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_resolution(r: PlanResolution) -> EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution {
|
||||
fn to_i2v_resolution(r: PlanResolution) -> Veo3p1FastImageToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution::TenEightyP,
|
||||
PlanResolution::SevenTwentyP => Veo3p1FastImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1FastImageToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1FastImageToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastFirstLastFrameToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1FastFirstLastFrameToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1FastFirstLastFrameToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_duration(d: PlanDuration) -> Veo3p1FastFirstLastFrameToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1FastFirstLastFrameToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_resolution(r: PlanResolution) -> Veo3p1FastFirstLastFrameToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1FastFirstLastFrameToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
// The reference-to-video endpoint has no `auto` aspect ratio; omit the field
|
||||
// and let fal apply its own default (16:9).
|
||||
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1FastReferenceToVideoAspectRatio> {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => None,
|
||||
PlanAspectRatio::SixteenByNine => Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine),
|
||||
PlanAspectRatio::NineBySixteen => Some(Veo3p1FastReferenceToVideoAspectRatio::NineBySixteen),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_reference_duration(d: PlanDuration) -> Veo3p1FastReferenceToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => Veo3p1FastReferenceToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1FastReferenceToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1FastReferenceToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_reference_resolution(r: PlanResolution) -> Veo3p1FastReferenceToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3p1FastReferenceToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1FastReferenceToVideoResolution::TenEightyP,
|
||||
PlanResolution::FourK => Veo3p1FastReferenceToVideoResolution::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_extend_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastExtendVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1FastExtendVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1FastExtendVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1FastExtendVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
// Extend-video has no 4k tier; the nearest supported resolution in either
|
||||
// direction is 1080p.
|
||||
fn to_extend_resolution(
|
||||
r: PlanResolution,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Veo3p1FastExtendVideoResolution, ArtcraftRouterError> {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Ok(Veo3p1FastExtendVideoResolution::SevenTwentyP),
|
||||
PlanResolution::TenEightyP => Ok(Veo3p1FastExtendVideoResolution::TenEightyP),
|
||||
PlanResolution::FourK => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", "Veo 3.1 Fast extend-video does not support 4k"))
|
||||
}
|
||||
_ => Ok(Veo3p1FastExtendVideoResolution::TenEightyP),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
const START_FRAME_URL: &str = "https://example.com/a.png";
|
||||
const END_FRAME_URL: &str = "https://example.com/b.png";
|
||||
const REFERENCE_IMAGE_URL: &str = "https://example.com/ref.png";
|
||||
const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4";
|
||||
|
||||
mod mode_selection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_frames_picks_t2v() {
|
||||
assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1FastMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_only_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_picks_flf() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::FirstLastFrameToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_only_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_pick_reference_to_video() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ReferenceToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_pick_extend_video() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ExtendVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_build_fn_wraps_request_enum() {
|
||||
match build_fal_veo_3p1_fast(base_builder()).expect("build") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(s)) => {
|
||||
assert!(matches!(s.mode, FalVeo3p1FastMode::TextToVideo(_)));
|
||||
}
|
||||
_ => panic!("expected Request(FalVeo3p1Fast)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1FastMode {
|
||||
match build_fal_veo_3p1_fast(b).expect("build") {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(s)) => s.mode,
|
||||
_ => panic!("expected FalVeo3p1Fast"),
|
||||
mod extend_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extend_maps_video_url_and_7s_duration() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.duration_seconds = Some(7);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1FastMode::ExtendVideo(r) => {
|
||||
assert_eq!(r.video_url, REFERENCE_VIDEO_URL);
|
||||
assert!(matches!(r.duration, Some(Veo3p1FastExtendVideoDuration::SevenSeconds)));
|
||||
}
|
||||
_ => panic!("expected extend"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_requires_exactly_one_video() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![
|
||||
REFERENCE_VIDEO_URL.to_string(),
|
||||
"https://example.com/other.mp4".to_string(),
|
||||
]));
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_rejects_start_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_rejects_end_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_rejects_reference_images() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_4k_errors_with_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_4k_maps_to_1080p_with_mitigation() {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = strategy;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1FastMode::ExtendVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(Veo3p1FastExtendVideoResolution::TenEightyP)));
|
||||
}
|
||||
_ => panic!("expected extend"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_frames_picks_t2v() {
|
||||
assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1FastMode::TextToVideo(_)));
|
||||
mod reference_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_maps_image_urls() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1FastMode::ReferenceToVideo(r) => {
|
||||
assert_eq!(r.image_urls, vec![REFERENCE_IMAGE_URL]);
|
||||
}
|
||||
_ => panic!("expected reference"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_rejects_start_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_rejects_end_frame() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_only_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ImageToVideo(_)));
|
||||
mod duration_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_4s() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(4);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1FastMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1FastTextToVideoDuration::FourSeconds)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_duration_errors_with_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_picks_flf() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::FirstLastFrame(_)));
|
||||
}
|
||||
mod resolution_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn end_only_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
assert!(build_fal_veo_3p1_fast(b).is_err());
|
||||
#[test]
|
||||
fn t2v_720p() {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::SevenTwentyP);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1FastMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(Veo3p1FastTextToVideoResolution::SevenTwentyP)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_4k_maps_to_four_k() {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1FastMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(Veo3p1FastTextToVideoResolution::FourK)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_combinatorial_pass() {
|
||||
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)];
|
||||
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), Some(RouterResolution::FourK)];
|
||||
let durations = [None, Some(4u16), Some(6), Some(8)];
|
||||
let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)];
|
||||
let audios = [None, Some(true), Some(false)];
|
||||
@@ -321,18 +716,31 @@ mod tests {
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.generate_audio = generate_audio;
|
||||
if frames >= 1 {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
}
|
||||
if frames == 2 {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
}
|
||||
assert!(build_fal_veo_3p1_fast(b).is_ok());
|
||||
assert!(build_fal_veo_3p1_fast_state(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(combos, 3 * 4 * 4 * 3 * 3);
|
||||
assert_eq!(combos, 4 * 4 * 4 * 3 * 3);
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1FastMode {
|
||||
build_fal_veo_3p1_fast_state(b).expect("build").mode
|
||||
}
|
||||
}
|
||||
|
||||
+199
-87
@@ -1,36 +1,34 @@
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook::EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds;
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::EnqueueVeo3p1FastImageToVideoDurationSeconds;
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::EnqueueVeo3p1FastTextToVideoDurationSeconds;
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_fast::request::{
|
||||
FalVeo3p1FastMode, FalVeo3p1FastRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo3p1FastCostState {
|
||||
pub duration_seconds: u64,
|
||||
pub generate_audio: bool,
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalVeo3p1FastCostState {
|
||||
pub fn from_request(request: &FalVeo3p1FastRequestState) -> Self {
|
||||
let (duration_seconds, generate_audio) = match &request.mode {
|
||||
FalVeo3p1FastMode::TextToVideo(req) => (t2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
|
||||
FalVeo3p1FastMode::ImageToVideo(req) => (i2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
|
||||
FalVeo3p1FastMode::FirstLastFrame(req) => (flf_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalVeo3p1FastMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1FastMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1FastMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1FastMode::ReferenceToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1FastMode::ExtendVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { duration_seconds, generate_audio }
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// $0.10/sec audio off, $0.15/sec audio on (720p/1080p).
|
||||
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
@@ -40,90 +38,204 @@ impl FalVeo3p1FastCostState {
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_duration_seconds(d: Option<EnqueueVeo3p1FastTextToVideoDurationSeconds>) -> u64 {
|
||||
match d {
|
||||
Some(EnqueueVeo3p1FastTextToVideoDurationSeconds::Four) => 4,
|
||||
Some(EnqueueVeo3p1FastTextToVideoDurationSeconds::Six) => 6,
|
||||
Some(EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight) | None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn i2v_duration_seconds(d: Option<EnqueueVeo3p1FastImageToVideoDurationSeconds>) -> u64 {
|
||||
match d {
|
||||
Some(EnqueueVeo3p1FastImageToVideoDurationSeconds::Four) => 4,
|
||||
Some(EnqueueVeo3p1FastImageToVideoDurationSeconds::Six) => 6,
|
||||
Some(EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight) | None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn flf_duration_seconds(d: Option<EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds>) -> u64 {
|
||||
match d {
|
||||
Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Four) => 4,
|
||||
Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six) => 6,
|
||||
Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight) | None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast_state;
|
||||
|
||||
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, frames: u8) -> u64 {
|
||||
use super::*;
|
||||
|
||||
// Veo 3.1 Fast pricing:
|
||||
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
|
||||
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
|
||||
// Extend-video has no 4k tier and defaults to 7s.
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Modality {
|
||||
TextToVideo,
|
||||
ImageToVideo,
|
||||
FirstLastFrame,
|
||||
Reference,
|
||||
Extend,
|
||||
}
|
||||
|
||||
mod text_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn t2v_4s_audio_on_is_60() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(true)), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_6s_audio_on_is_90() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(6), None, Some(true)), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_on_is_120() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), None, Some(true)), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_4s_audio_off_is_40() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(false)), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_off_is_80() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), None, Some(false)), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_on_4k_is_280() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(true)), 280);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_off_4k_is_240() {
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(false)), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_defaults_are_8s_720p_audio_on_120() {
|
||||
// duration=None→8s, resolution=None→720p, audio=None→on.
|
||||
assert_eq!(cost_cents(Modality::TextToVideo, None, None, None), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_1080p_bills_same_as_720p() {
|
||||
assert_eq!(
|
||||
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true)),
|
||||
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod image_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn i2v_6s_audio_on_is_90() {
|
||||
assert_eq!(cost_cents(Modality::ImageToVideo, Some(6), None, Some(true)), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_8s_audio_off_1080p_is_80() {
|
||||
assert_eq!(cost_cents(Modality::ImageToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(false)), 80);
|
||||
}
|
||||
}
|
||||
|
||||
mod first_last_frame_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flf_6s_audio_on_is_90() {
|
||||
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(6), None, Some(true)), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flf_8s_audio_on_4k_is_280() {
|
||||
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(8), Some(RouterResolution::FourK), Some(true)), 280);
|
||||
}
|
||||
}
|
||||
|
||||
mod reference_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_8s_audio_on_1080p_is_120() {
|
||||
assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::TenEightyP), Some(true)), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_4s_audio_off_720p_is_40() {
|
||||
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 40);
|
||||
}
|
||||
}
|
||||
|
||||
mod extend_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extend_7s_audio_on_is_105() {
|
||||
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(true)), 105);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_7s_audio_off_is_70() {
|
||||
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(false)), 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_8s_audio_on_is_120() {
|
||||
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_defaults_are_7s_audio_on_105() {
|
||||
// duration=None→7s (fal's extend default), audio=None→on.
|
||||
assert_eq!(cost_cents(Modality::Extend, None, None, None), 105);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_i2v_flf_and_reference_price_identically() {
|
||||
let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true));
|
||||
for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] {
|
||||
assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_costs_more_than_no_audio() {
|
||||
assert!(
|
||||
cost_cents(Modality::TextToVideo, Some(8), None, Some(false))
|
||||
< cost_cents(Modality::TextToVideo, Some(8), None, Some(true))
|
||||
);
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
modality: Modality,
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
generate_audio: Option<bool>,
|
||||
) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
if frames >= 1 {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
match modality {
|
||||
Modality::TextToVideo => {}
|
||||
Modality::ImageToVideo => {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
Modality::FirstLastFrame => {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
}
|
||||
Modality::Reference => {
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
|
||||
}
|
||||
Modality::Extend => {
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/in.mp4".to_string()]));
|
||||
}
|
||||
}
|
||||
if frames == 2 {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
}
|
||||
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
|
||||
}
|
||||
|
||||
mod numeric_literal_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn audio_on_4s_is_60() { assert_eq!(cost_cents(Some(4), Some(true), 0), 60); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true), 0), 90); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true), 0), 120); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_4s_is_40() { assert_eq!(cost_cents(Some(4), Some(false), 0), 40); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_8s_is_80() { assert_eq!(cost_cents(Some(8), Some(false), 0), 80); }
|
||||
|
||||
#[test]
|
||||
fn no_duration_defaults_to_8s() {
|
||||
assert_eq!(cost_cents(None, Some(true), 0), 120);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_modes_price_identically() {
|
||||
let t2v = cost_cents(Some(6), Some(true), 0);
|
||||
let i2v = cost_cents(Some(6), Some(true), 1);
|
||||
let flf = cost_cents(Some(6), Some(true), 2);
|
||||
assert_eq!(t2v, i2v);
|
||||
assert_eq!(i2v, flf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_costs_more_than_no_audio() {
|
||||
assert!(cost_cents(Some(8), Some(false), 0) < cost_cents(Some(8), Some(true), 0));
|
||||
let state = build_fal_veo_3p1_fast_state(b).expect("build state");
|
||||
FalVeo3p1FastCostState::from_request(&state)
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.expect("cost")
|
||||
}
|
||||
}
|
||||
|
||||
+95
-61
@@ -1,33 +1,26 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook::{
|
||||
enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook,
|
||||
EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs,
|
||||
EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::{
|
||||
enqueue_veo_3p1_fast_image_to_video_webhook, EnqueueVeo3p1FastImageToVideoArgs,
|
||||
EnqueueVeo3p1FastImageToVideoRequest,
|
||||
};
|
||||
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::{
|
||||
enqueue_veo_3p1_fast_text_to_video_webhook, EnqueueVeo3p1FastTextToVideoArgs,
|
||||
EnqueueVeo3p1FastTextToVideoRequest,
|
||||
};
|
||||
use fal_client::requests::api::video::extend::veo_3p1_fast::api::Veo3p1FastExtendVideoRequest;
|
||||
use fal_client::requests::api::video::image::veo_3p1_fast::api::Veo3p1FastImageToVideoRequest;
|
||||
use fal_client::requests::api::video::images::veo_3p1_fast::api::Veo3p1FastFirstLastFrameToVideoRequest;
|
||||
use fal_client::requests::api::video::reference::veo_3p1_fast::api::Veo3p1FastReferenceToVideoRequest;
|
||||
use fal_client::requests::api::video::text::veo_3p1_fast::api::Veo3p1FastTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalVeo3p1FastMode {
|
||||
TextToVideo(EnqueueVeo3p1FastTextToVideoRequest),
|
||||
ImageToVideo(EnqueueVeo3p1FastImageToVideoRequest),
|
||||
FirstLastFrame(EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest),
|
||||
TextToVideo(Veo3p1FastTextToVideoRequest),
|
||||
ImageToVideo(Veo3p1FastImageToVideoRequest),
|
||||
FirstLastFrameToVideo(Veo3p1FastFirstLastFrameToVideoRequest),
|
||||
ReferenceToVideo(Veo3p1FastReferenceToVideoRequest),
|
||||
ExtendVideo(Veo3p1FastExtendVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -37,65 +30,80 @@ pub struct FalVeo3p1FastRequestState {
|
||||
|
||||
impl FalVeo3p1FastRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let webhook_url = client.webhook_url.as_deref()
|
||||
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
|
||||
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
|
||||
FalVeo3p1FastMode::TextToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueVeo3p1FastTextToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_veo_3p1_fast_text_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalVeo3p1FastMode::ImageToVideo(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueVeo3p1FastImageToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_veo_3p1_fast_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
FalVeo3p1FastMode::FirstLastFrame(request) => {
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let args = EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs {
|
||||
request: request.clone(),
|
||||
webhook_url,
|
||||
api_key: &client.api_key,
|
||||
};
|
||||
(enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook(args).await, outbound)
|
||||
}
|
||||
};
|
||||
match &self.mode {
|
||||
FalVeo3p1FastMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1FastMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1FastMode::FirstLastFrameToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1FastMode::ReferenceToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1FastMode::ExtendVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let webhook_response = webhook_response
|
||||
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
|
||||
// ── Helpers ──
|
||||
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: webhook_response.request_id,
|
||||
gateway_request_id: webhook_response.gateway_request_id,
|
||||
maybe_status_url: None,
|
||||
maybe_response_url: None,
|
||||
maybe_outbound_request: Some(outbound_request),
|
||||
}))
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
|
||||
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::test_helpers::get_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_text_to_video_4s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("a candle flickering in the dark".to_string()),
|
||||
@@ -106,7 +114,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_image_to_video_6s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog leaps into the lake.".to_string()),
|
||||
@@ -117,6 +125,32 @@ mod tests {
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_reference_to_video_4s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog runs across a sunlit meadow".to_string()),
|
||||
reference_images: Some(ImageListRef::Urls(vec![JUNO_AT_LAKE_IMAGE_URL.to_string()])),
|
||||
duration_seconds: Some(4),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
}).await;
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_extend_video_7s() {
|
||||
let r = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the scene continues naturally, keeping the same motion and style".to_string()),
|
||||
reference_videos: Some(VideoListRef::Urls(vec![ANGRY_SHIBA_VIDEO_URL.to_string()])),
|
||||
duration_seconds: Some(7),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
}).await;
|
||||
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
|
||||
}
|
||||
|
||||
fn builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Fast,
|
||||
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
use fal_client::requests::api::video::image::veo_3p1_lite::api::{
|
||||
Veo3p1LiteImageToVideoAspectRatio, Veo3p1LiteImageToVideoDuration, Veo3p1LiteImageToVideoRequest,
|
||||
Veo3p1LiteImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::images::veo_3p1_lite::api::{
|
||||
Veo3p1LiteFirstLastFrameToVideoAspectRatio, Veo3p1LiteFirstLastFrameToVideoDuration,
|
||||
Veo3p1LiteFirstLastFrameToVideoRequest, Veo3p1LiteFirstLastFrameToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::text::veo_3p1_lite::api::{
|
||||
Veo3p1LiteTextToVideoAspectRatio, Veo3p1LiteTextToVideoDuration, Veo3p1LiteTextToVideoRequest,
|
||||
Veo3p1LiteTextToVideoResolution,
|
||||
};
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::{
|
||||
FalVeo3p1LiteMode, FalVeo3p1LiteRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanAspectRatio {
|
||||
Auto,
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanResolution {
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanDuration {
|
||||
Four,
|
||||
Six,
|
||||
Eight,
|
||||
}
|
||||
|
||||
pub fn build_fal_veo_3p1_lite(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_veo_3p1_lite_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Lite(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_veo_3p1_lite_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalVeo3p1LiteRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Lite has no reference-to-video or extend-video endpoints on fal.
|
||||
if has_reference_videos(&builder.reference_videos) {
|
||||
return Err(unsupported(
|
||||
"reference_videos",
|
||||
"Veo 3.1 Lite does not support extend-video (reference videos)",
|
||||
));
|
||||
}
|
||||
if has_reference_images(&builder.reference_images) {
|
||||
return Err(unsupported(
|
||||
"reference_images",
|
||||
"Veo 3.1 Lite does not support reference-to-video (reference images)",
|
||||
));
|
||||
}
|
||||
|
||||
let start = optional_url(builder.start_frame.clone())?;
|
||||
let end = optional_url(builder.end_frame.clone())?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let negative_prompt = builder.negative_prompt.clone();
|
||||
let generate_audio = builder.generate_audio;
|
||||
|
||||
let mode = match (start, end) {
|
||||
(None, None) => FalVeo3p1LiteMode::TextToVideo(Veo3p1LiteTextToVideoRequest {
|
||||
prompt,
|
||||
aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio),
|
||||
duration: duration.map(to_t2v_duration),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
negative_prompt,
|
||||
generate_audio,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
(Some(image_url), None) => FalVeo3p1LiteMode::ImageToVideo(Veo3p1LiteImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
|
||||
duration: duration.map(to_i2v_duration),
|
||||
resolution: resolution.map(to_i2v_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
}),
|
||||
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1LiteMode::FirstLastFrameToVideo(
|
||||
Veo3p1LiteFirstLastFrameToVideoRequest {
|
||||
prompt,
|
||||
first_frame_url,
|
||||
last_frame_url,
|
||||
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
|
||||
duration: duration.map(to_flf_duration),
|
||||
resolution: resolution.map(to_flf_resolution),
|
||||
generate_audio,
|
||||
negative_prompt,
|
||||
seed: None,
|
||||
auto_fix: None,
|
||||
safety_tolerance: None,
|
||||
},
|
||||
),
|
||||
(None, Some(_)) => {
|
||||
return Err(unsupported(
|
||||
"end_frame",
|
||||
"Veo 3.1 Lite requires a start_frame when end_frame is provided",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FalVeo3p1LiteRequestState { mode })
|
||||
}
|
||||
|
||||
fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_reference_images(refs: &Option<ImageListRef>) -> bool {
|
||||
match refs {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_reference_videos(refs: &Option<VideoListRef>) -> bool {
|
||||
match refs {
|
||||
None => false,
|
||||
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
|
||||
use PlanAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(None),
|
||||
|
||||
Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
|
||||
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
|
||||
}
|
||||
_ => Ok(Some(Ar::Auto)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
|
||||
use PlanResolution as R;
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)),
|
||||
// Lite has no 4k tier; the nearest supported resolution in either
|
||||
// direction is 1080p.
|
||||
Some(RouterResolution::FourK) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", "Veo 3.1 Lite does not support 4k"))
|
||||
}
|
||||
_ => Ok(Some(R::TenEightyP)),
|
||||
},
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("resolution", &format!("{:?}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::SevenTwentyP)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(4) => Ok(Some(PlanDuration::Four)),
|
||||
Some(6) => Ok(Some(PlanDuration::Six)),
|
||||
Some(8) => Ok(Some(PlanDuration::Eight)),
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(unsupported("duration_seconds", &format!("{}", other)))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
|
||||
0..=4 => PlanDuration::Four,
|
||||
5..=6 => PlanDuration::Six,
|
||||
_ => PlanDuration::Eight,
|
||||
})),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
|
||||
0..=5 => PlanDuration::Four,
|
||||
6..=7 => PlanDuration::Six,
|
||||
_ => PlanDuration::Eight,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field,
|
||||
value: value.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// The Lite text-to-video endpoint has no `auto` aspect ratio; omit the field
|
||||
// and let fal apply its own default (16:9).
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1LiteTextToVideoAspectRatio> {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => None,
|
||||
PlanAspectRatio::SixteenByNine => Some(Veo3p1LiteTextToVideoAspectRatio::SixteenByNine),
|
||||
PlanAspectRatio::NineBySixteen => Some(Veo3p1LiteTextToVideoAspectRatio::NineBySixteen),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_duration(d: PlanDuration) -> Veo3p1LiteTextToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => Veo3p1LiteTextToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1LiteTextToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1LiteTextToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: PlanResolution) -> Veo3p1LiteTextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3p1LiteTextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1LiteTextToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1LiteImageToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1LiteImageToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1LiteImageToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1LiteImageToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_duration(d: PlanDuration) -> Veo3p1LiteImageToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => Veo3p1LiteImageToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1LiteImageToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1LiteImageToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: PlanResolution) -> Veo3p1LiteImageToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3p1LiteImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1LiteImageToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1LiteFirstLastFrameToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::Auto => Veo3p1LiteFirstLastFrameToVideoAspectRatio::Auto,
|
||||
PlanAspectRatio::SixteenByNine => Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => Veo3p1LiteFirstLastFrameToVideoAspectRatio::NineBySixteen,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_duration(d: PlanDuration) -> Veo3p1LiteFirstLastFrameToVideoDuration {
|
||||
match d {
|
||||
PlanDuration::Four => Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds,
|
||||
PlanDuration::Six => Veo3p1LiteFirstLastFrameToVideoDuration::SixSeconds,
|
||||
PlanDuration::Eight => Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_flf_resolution(r: PlanResolution) -> Veo3p1LiteFirstLastFrameToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::SevenTwentyP => Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
const START_FRAME_URL: &str = "https://example.com/a.png";
|
||||
const END_FRAME_URL: &str = "https://example.com/b.png";
|
||||
const REFERENCE_IMAGE_URL: &str = "https://example.com/ref.png";
|
||||
const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4";
|
||||
|
||||
mod mode_selection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_frames_picks_t2v() {
|
||||
assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1LiteMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_only_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_picks_first_last_frame() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::FirstLastFrameToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_only_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
assert!(build_fal_veo_3p1_lite_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod unsupported_modalities {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_images_are_rejected() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
|
||||
assert!(build_fal_veo_3p1_lite_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_are_rejected() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
|
||||
assert!(build_fal_veo_3p1_lite_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_reference_lists_are_ignored() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![]));
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec![]));
|
||||
assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::TextToVideo(_)));
|
||||
}
|
||||
}
|
||||
|
||||
mod duration_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_4s() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(4);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1LiteMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::FourSeconds)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_duration_errors_with_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_3p1_lite_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5s_pay_more_upgrades_to_6() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1LiteMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::SixSeconds)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_5s_pay_less_downgrades_to_4() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(5);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1LiteMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::FourSeconds)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn t2v_720p() {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::SevenTwentyP);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1LiteMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_errors_with_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_veo_3p1_lite_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_maps_to_1080p_with_mitigation() {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = strategy;
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1LiteMode::TextToVideo(r) => {
|
||||
assert!(matches!(r.resolution, Some(Veo3p1LiteTextToVideoResolution::TenEightyP)));
|
||||
}
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_auto_aspect_ratio_is_omitted() {
|
||||
// Lite t2v has no `auto` wire value; Auto must map to None.
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1LiteMode::TextToVideo(r) => assert!(r.aspect_ratio.is_none()),
|
||||
_ => panic!("expected t2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_auto_aspect_ratio_maps_to_auto() {
|
||||
let mut b = base_builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
match unwrap_mode(b) {
|
||||
FalVeo3p1LiteMode::ImageToVideo(r) => {
|
||||
assert!(matches!(r.aspect_ratio, Some(Veo3p1LiteImageToVideoAspectRatio::Auto)));
|
||||
}
|
||||
_ => panic!("expected i2v"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_combinatorial_pass() {
|
||||
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)];
|
||||
let durations = [None, Some(4u16), Some(6), Some(8)];
|
||||
let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)];
|
||||
let audios = [None, Some(true), Some(false)];
|
||||
|
||||
let mut combos = 0;
|
||||
for &resolution in &resolutions {
|
||||
for &duration in &durations {
|
||||
for &aspect_ratio in &aspect_ratios {
|
||||
for &generate_audio in &audios {
|
||||
for frames in [0, 1, 2] {
|
||||
let mut b = base_builder();
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = duration;
|
||||
b.aspect_ratio = aspect_ratio;
|
||||
b.generate_audio = generate_audio;
|
||||
if frames >= 1 {
|
||||
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
|
||||
}
|
||||
if frames == 2 {
|
||||
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
|
||||
}
|
||||
assert!(build_fal_veo_3p1_lite_state(b).is_ok());
|
||||
combos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(combos, 3 * 4 * 4 * 3 * 3);
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Lite,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1LiteMode {
|
||||
build_fal_veo_3p1_lite_state(b).expect("build").mode
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::{
|
||||
FalVeo3p1LiteMode, FalVeo3p1LiteRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo3p1LiteCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalVeo3p1LiteCostState {
|
||||
pub fn from_request(request: &FalVeo3p1LiteRequestState) -> Self {
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalVeo3p1LiteMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1LiteMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalVeo3p1LiteMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite_state;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Veo 3.1 Lite pricing (both resolution and audio move the rate; no 4k):
|
||||
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
|
||||
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
|
||||
|
||||
mod text_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_on_720p_is_40() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_off_720p_is_24() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 0), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_on_1080p_is_64() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 0), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_8s_audio_off_1080p_is_40() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false), 0), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_4s_audio_on_720p_is_20() {
|
||||
assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(true), 0), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_6s_audio_on_720p_is_30() {
|
||||
assert_eq!(cost_cents(Some(6), Some(RouterResolution::SevenTwentyP), Some(true), 0), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_defaults_are_8s_720p_audio_on_40() {
|
||||
// duration=None→8s, resolution=None→720p, audio=None→on.
|
||||
assert_eq!(cost_cents(None, None, None, 0), 40);
|
||||
}
|
||||
}
|
||||
|
||||
mod image_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn i2v_8s_audio_off_720p_is_24() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 1), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_6s_audio_on_720p_is_30() {
|
||||
assert_eq!(cost_cents(Some(6), Some(RouterResolution::SevenTwentyP), Some(true), 1), 30);
|
||||
}
|
||||
}
|
||||
|
||||
mod first_last_frame_to_video {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flf_8s_audio_on_1080p_is_64() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 2), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flf_4s_audio_off_720p_is_12() {
|
||||
assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(false), 2), 12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_modes_price_identically() {
|
||||
let t2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 0);
|
||||
let i2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 1);
|
||||
let flf = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 2);
|
||||
assert_eq!(t2v, i2v);
|
||||
assert_eq!(i2v, flf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_costs_more_than_no_audio() {
|
||||
assert!(
|
||||
cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 0)
|
||||
< cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_costs_more_than_seven_twenty() {
|
||||
assert!(
|
||||
cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0)
|
||||
< cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 0)
|
||||
);
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
generate_audio: Option<bool>,
|
||||
frames: u8,
|
||||
) -> u64 {
|
||||
let mut b = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Lite,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
if frames >= 1 {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
|
||||
}
|
||||
if frames == 2 {
|
||||
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
|
||||
}
|
||||
let state = build_fal_veo_3p1_lite_state(b).expect("build state");
|
||||
FalVeo3p1LiteCostState::from_request(&state)
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.expect("cost")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests::api::video::image::veo_3p1_lite::api::Veo3p1LiteImageToVideoRequest;
|
||||
use fal_client::requests::api::video::images::veo_3p1_lite::api::Veo3p1LiteFirstLastFrameToVideoRequest;
|
||||
use fal_client::requests::api::video::text::veo_3p1_lite::api::Veo3p1LiteTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalVeo3p1LiteMode {
|
||||
TextToVideo(Veo3p1LiteTextToVideoRequest),
|
||||
ImageToVideo(Veo3p1LiteImageToVideoRequest),
|
||||
FirstLastFrameToVideo(Veo3p1LiteFirstLastFrameToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalVeo3p1LiteRequestState {
|
||||
pub mode: FalVeo3p1LiteMode,
|
||||
}
|
||||
|
||||
impl FalVeo3p1LiteRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
match &self.mode {
|
||||
FalVeo3p1LiteMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1LiteMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
FalVeo3p1LiteMode::FirstLastFrameToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite_state;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_text_to_video_4s() {
|
||||
let state = build_state(GenerateVideoRequestBuilder {
|
||||
prompt: Some("a candle flickering in the dark".to_string()),
|
||||
duration_seconds: Some(4),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
});
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_image_to_video_4s() {
|
||||
let state = build_state(GenerateVideoRequestBuilder {
|
||||
prompt: Some("the dog leaps into the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
duration_seconds: Some(4),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
});
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn live_first_last_frame_4s() {
|
||||
let state = build_state(GenerateVideoRequestBuilder {
|
||||
prompt: Some("a smooth transition between scenes".to_string()),
|
||||
start_frame: Some(ImageRef::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string())),
|
||||
end_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
duration_seconds: Some(4),
|
||||
generate_audio: Some(false),
|
||||
..builder()
|
||||
});
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
fn builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Veo3p1Lite,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_state(b: GenerateVideoRequestBuilder) -> FalVeo3p1LiteRequestState {
|
||||
build_fal_veo_3p1_lite_state(b).expect("build state")
|
||||
}
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
use fal_client::requests::api::video::image::vidu_q3::api::{
|
||||
ViduQ3ImageToVideoRequest, ViduQ3ImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video::api::{
|
||||
ViduQ3ReferenceToVideoAspectRatio, ViduQ3ReferenceToVideoRequest, ViduQ3ReferenceToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::{
|
||||
ViduQ3ReferenceToVideoMixAspectRatio, ViduQ3ReferenceToVideoMixRequest,
|
||||
ViduQ3ReferenceToVideoMixResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::text::vidu_q3::api::{
|
||||
ViduQ3TextToVideoAspectRatio, ViduQ3TextToVideoRequest, ViduQ3TextToVideoResolution,
|
||||
};
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::request::{
|
||||
FalViduQ3Mode, FalViduQ3RequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
const MAX_REFERENCE_IMAGES: usize = 4;
|
||||
const MIN_DURATION_SECONDS: u16 = 1;
|
||||
const MAX_DURATION_SECONDS: u16 = 16;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanAspectRatio {
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
FourByThree,
|
||||
ThreeByFour,
|
||||
Square,
|
||||
}
|
||||
|
||||
/// Vidu also offers 360p, but no `RouterResolution` maps to it (fal bills 360p
|
||||
/// and 540p identically, so 540p is always the better pick).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanResolution {
|
||||
FiveFortyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
pub fn build_fal_vidu_q3(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_vidu_q3_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalViduQ3(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_vidu_q3_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalViduQ3RequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
reject_reference_videos(&builder.reference_videos)?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let audio = builder.generate_audio;
|
||||
let reference_image_urls = resolve_reference_image_urls(builder.reference_images.clone())?;
|
||||
let start_image_url = optional_url(builder.start_frame.clone())?;
|
||||
let end_image_url = optional_url(builder.end_frame.clone())?;
|
||||
|
||||
// Dispatch: reference_images → reference-to-video; start_frame → image; else → text.
|
||||
let mode = if !reference_image_urls.is_empty() {
|
||||
if start_image_url.is_some() || end_image_url.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Vidu Q3 reference-to-video cannot also accept a start_frame or end_frame".to_string(),
|
||||
}));
|
||||
}
|
||||
if reference_image_urls.len() > MAX_REFERENCE_IMAGES {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: format!(
|
||||
"Vidu Q3 supports at most {} reference images, got {}",
|
||||
MAX_REFERENCE_IMAGES,
|
||||
reference_image_urls.len(),
|
||||
),
|
||||
}));
|
||||
}
|
||||
// The plain reference endpoint keeps a single subject consistent; the /mix
|
||||
// variant blends multiple subjects, so 2+ references route there.
|
||||
if reference_image_urls.len() == 1 {
|
||||
FalViduQ3Mode::ReferenceToVideo(ViduQ3ReferenceToVideoRequest {
|
||||
prompt,
|
||||
reference_image_urls,
|
||||
duration,
|
||||
seed: None,
|
||||
aspect_ratio: aspect_ratio.map(to_reference_aspect_ratio),
|
||||
resolution: resolution.map(to_reference_resolution),
|
||||
audio,
|
||||
})
|
||||
} else {
|
||||
FalViduQ3Mode::ReferenceToVideoMix(ViduQ3ReferenceToVideoMixRequest {
|
||||
prompt,
|
||||
reference_image_urls,
|
||||
duration,
|
||||
seed: None,
|
||||
aspect_ratio: aspect_ratio.map(to_mix_aspect_ratio),
|
||||
resolution: resolution.map(to_mix_resolution),
|
||||
audio,
|
||||
})
|
||||
}
|
||||
} else if let Some(image_url) = start_image_url {
|
||||
let i2v_resolution = resolution.map(to_i2v_resolution);
|
||||
check_i2v_end_frame_resolution(i2v_resolution, end_image_url.is_some())?;
|
||||
// Image-to-video has no aspect_ratio input — the output follows the start
|
||||
// frame — so any requested aspect ratio is silently dropped here.
|
||||
FalViduQ3Mode::ImageToVideo(ViduQ3ImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
end_image_url,
|
||||
duration,
|
||||
seed: None,
|
||||
resolution: i2v_resolution,
|
||||
audio,
|
||||
})
|
||||
} else {
|
||||
if end_image_url.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Vidu Q3 requires a start_frame when end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
FalViduQ3Mode::TextToVideo(ViduQ3TextToVideoRequest {
|
||||
prompt,
|
||||
duration,
|
||||
seed: None,
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
audio,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(FalViduQ3RequestState { mode })
|
||||
}
|
||||
|
||||
// ── Input helpers ──
|
||||
|
||||
fn reject_reference_videos(
|
||||
reference_videos: &Option<VideoListRef>,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let has_reference_videos = match reference_videos {
|
||||
None => false,
|
||||
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
if has_reference_videos {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_videos",
|
||||
value: "Vidu Q3 does not support video references".to_string(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_reference_image_urls(
|
||||
reference_images: Option<ImageListRef>,
|
||||
) -> Result<Vec<String>, ArtcraftRouterError> {
|
||||
match reference_images {
|
||||
None => Ok(vec![]),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(urls),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// fal rejects 360p image-to-video requests that include an end frame.
|
||||
/// `plan_resolution` can't currently produce 360p, but guard anyway so a
|
||||
/// future mapping change can't silently ship the rejected combination.
|
||||
fn check_i2v_end_frame_resolution(
|
||||
resolution: Option<ViduQ3ImageToVideoResolution>,
|
||||
has_end_frame: bool,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
if has_end_frame && matches!(resolution, Some(ViduQ3ImageToVideoResolution::ThreeSixtyP)) {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: "Vidu Q3 image-to-video does not support 360p when an end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
|
||||
use PlanAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(None),
|
||||
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
|
||||
|
||||
Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", other),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(Ar::SixteenByNine)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Vidu doesn't offer 480p; 540p is the nearest supported size (and bills
|
||||
// at the same per-second rate as 360p, so nothing cheaper is lost).
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(PlanResolution::FiveFortyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", other),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::FiveFortyP)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u8>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => {
|
||||
Ok(Some(seconds as u8))
|
||||
}
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaf converters ──
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> ViduQ3TextToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::SixteenByNine => ViduQ3TextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => ViduQ3TextToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::FourByThree => ViduQ3TextToVideoAspectRatio::FourByThree,
|
||||
PlanAspectRatio::ThreeByFour => ViduQ3TextToVideoAspectRatio::ThreeByFour,
|
||||
PlanAspectRatio::Square => ViduQ3TextToVideoAspectRatio::Square,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> ViduQ3ReferenceToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::SixteenByNine => ViduQ3ReferenceToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => ViduQ3ReferenceToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::FourByThree => ViduQ3ReferenceToVideoAspectRatio::FourByThree,
|
||||
PlanAspectRatio::ThreeByFour => ViduQ3ReferenceToVideoAspectRatio::ThreeByFour,
|
||||
PlanAspectRatio::Square => ViduQ3ReferenceToVideoAspectRatio::Square,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_mix_aspect_ratio(a: PlanAspectRatio) -> ViduQ3ReferenceToVideoMixAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::SixteenByNine => ViduQ3ReferenceToVideoMixAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => ViduQ3ReferenceToVideoMixAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::FourByThree => ViduQ3ReferenceToVideoMixAspectRatio::FourByThree,
|
||||
PlanAspectRatio::ThreeByFour => ViduQ3ReferenceToVideoMixAspectRatio::ThreeByFour,
|
||||
PlanAspectRatio::Square => ViduQ3ReferenceToVideoMixAspectRatio::Square,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: PlanResolution) -> ViduQ3TextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::FiveFortyP => ViduQ3TextToVideoResolution::FiveFortyP,
|
||||
PlanResolution::SevenTwentyP => ViduQ3TextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => ViduQ3TextToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: PlanResolution) -> ViduQ3ImageToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::FiveFortyP => ViduQ3ImageToVideoResolution::FiveFortyP,
|
||||
PlanResolution::SevenTwentyP => ViduQ3ImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => ViduQ3ImageToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_reference_resolution(r: PlanResolution) -> ViduQ3ReferenceToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::FiveFortyP => ViduQ3ReferenceToVideoResolution::FiveFortyP,
|
||||
PlanResolution::SevenTwentyP => ViduQ3ReferenceToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => ViduQ3ReferenceToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_mix_resolution(r: PlanResolution) -> ViduQ3ReferenceToVideoMixResolution {
|
||||
match r {
|
||||
PlanResolution::FiveFortyP => ViduQ3ReferenceToVideoMixResolution::FiveFortyP,
|
||||
PlanResolution::SevenTwentyP => ViduQ3ReferenceToVideoMixResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => ViduQ3ReferenceToVideoMixResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
const START_URL: &str = "https://example.com/start.png";
|
||||
const END_URL: &str = "https://example.com/end.png";
|
||||
|
||||
mod dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_inputs_picks_t2v() {
|
||||
let state = build_fal_vidu_q3_state(base_builder()).expect("build");
|
||||
assert!(matches!(state.mode, FalViduQ3Mode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
let state = build_fal_vidu_q3_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalViduQ3Mode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frame_picks_i2v_with_end_image_url() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
let state = build_fal_vidu_q3_state(b).expect("build");
|
||||
let FalViduQ3Mode::ImageToVideo(req) = state.mode else {
|
||||
panic!("expected ImageToVideo");
|
||||
};
|
||||
assert_eq!(req.image_url, START_URL);
|
||||
assert_eq!(req.end_image_url.as_deref(), Some(END_URL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
assert!(build_fal_vidu_q3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_reference_image_picks_reference_to_video() {
|
||||
let state = build_fal_vidu_q3_state(builder_with_references(1)).expect("build");
|
||||
assert!(matches!(state.mode, FalViduQ3Mode::ReferenceToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_reference_images_pick_reference_to_video_mix() {
|
||||
let state = build_fal_vidu_q3_state(builder_with_references(3)).expect("build");
|
||||
let FalViduQ3Mode::ReferenceToVideoMix(req) = state.mode else {
|
||||
panic!("expected ReferenceToVideoMix");
|
||||
};
|
||||
assert_eq!(req.reference_image_urls.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_reference_images_error() {
|
||||
assert!(build_fal_vidu_q3_state(builder_with_references(5)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_with_start_frame_error() {
|
||||
let mut b = builder_with_references(2);
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
assert!(build_fal_vidu_q3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_error() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()]));
|
||||
assert!(build_fal_vidu_q3_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_20_errors_under_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(20);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_vidu_q3_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_20_clamps_to_16_under_pay_less() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(20);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
let state = build_fal_vidu_q3_state(b).expect("build");
|
||||
let FalViduQ3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.duration, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_480p_maps_to_540p() {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
let state = build_fal_vidu_q3_state(b).expect("build");
|
||||
let FalViduQ3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.resolution, Some(ViduQ3TextToVideoResolution::FiveFortyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_aspect_ratio_is_silently_dropped() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
// Image-to-video has no aspect_ratio field; the request still builds.
|
||||
let state = build_fal_vidu_q3_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalViduQ3Mode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_360p_with_end_frame_is_rejected_by_guard() {
|
||||
// 360p is unreachable from RouterResolution today, so exercise the
|
||||
// guard directly against the fal-level resolution enum.
|
||||
let result = check_i2v_end_frame_resolution(
|
||||
Some(ViduQ3ImageToVideoResolution::ThreeSixtyP),
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(check_i2v_end_frame_resolution(Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), false).is_ok());
|
||||
assert!(check_i2v_end_frame_resolution(Some(ViduQ3ImageToVideoResolution::SevenTwentyP), true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_audio_propagates() {
|
||||
let mut b = base_builder();
|
||||
b.generate_audio = Some(false);
|
||||
let state = build_fal_vidu_q3_state(b).expect("build");
|
||||
let FalViduQ3Mode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.audio, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with_references(count: usize) -> GenerateVideoRequestBuilder {
|
||||
let urls = (0..count)
|
||||
.map(|i| format!("https://example.com/ref-{}.png", i))
|
||||
.collect();
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(urls));
|
||||
b
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::request::{
|
||||
FalViduQ3Mode, FalViduQ3RequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalViduQ3CostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalViduQ3CostState {
|
||||
pub fn from_request(request: &FalViduQ3RequestState) -> Self {
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalViduQ3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalViduQ3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalViduQ3Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalViduQ3Mode::ReferenceToVideoMix(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3_state;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Pricing (from fal_client's vidu_q3 cost modules):
|
||||
// 360p/540p: $0.07/sec → 5s = 35¢
|
||||
// 720p/1080p: $0.154/sec → 5s = 77¢, 10s = 154¢ (ceil to whole cents)
|
||||
// fal defaults when unset: duration = 5s, resolution = 720p.
|
||||
// All four modes (text/image/reference/mix) bill identically.
|
||||
//
|
||||
// NB: `build2()` isn't wired up for Vidu Q3 yet, so these tests go through
|
||||
// `build_fal_vidu_q3_state()` directly.
|
||||
|
||||
#[test]
|
||||
fn t2v_default_settings_is_77() {
|
||||
assert_eq!(cost_cents(base_builder(None, None)), 77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_5s_720p_is_77() {
|
||||
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_5s_540p_is_35() {
|
||||
// RouterResolution::FourEightyP maps to Vidu's 540p.
|
||||
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::FourEightyP))), 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_10s_1080p_is_154() {
|
||||
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 154);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_3s_720p_rounds_up_to_47() {
|
||||
// 3 × 15.4¢ = 46.2¢ → 47¢.
|
||||
assert_eq!(cost_cents(base_builder(Some(3), Some(RouterResolution::SevenTwentyP))), 47);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_5s_720p_is_77() {
|
||||
let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP));
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
assert_eq!(cost_cents(b), 77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_5s_540p_is_35() {
|
||||
let mut b = base_builder(Some(5), Some(RouterResolution::FourEightyP));
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![
|
||||
"https://example.com/ref-0.png".to_string(),
|
||||
]));
|
||||
assert_eq!(cost_cents(b), 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_mix_10s_1080p_is_154() {
|
||||
let mut b = base_builder(Some(10), Some(RouterResolution::TenEightyP));
|
||||
b.reference_images = Some(ImageListRef::Urls(vec![
|
||||
"https://example.com/ref-0.png".to_string(),
|
||||
"https://example.com/ref-1.png".to_string(),
|
||||
"https://example.com/ref-2.png".to_string(),
|
||||
]));
|
||||
assert_eq!(cost_cents(b), 154);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_cost_forwards_cents() {
|
||||
let estimate = FalViduQ3CostState { cost_in_usd_cents: 77 }.estimate_cost();
|
||||
assert_eq!(estimate.cost_in_usd_cents, Some(77));
|
||||
assert_eq!(estimate.cost_in_credits, Some(77));
|
||||
assert!(!estimate.is_free);
|
||||
}
|
||||
|
||||
fn base_builder(
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
) -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
|
||||
let state = build_fal_vidu_q3_state(builder).expect("build");
|
||||
FalViduQ3CostState::from_request(&state).cost_in_usd_cents
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests::api::video::image::vidu_q3::api::ViduQ3ImageToVideoRequest;
|
||||
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video::api::ViduQ3ReferenceToVideoRequest;
|
||||
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::ViduQ3ReferenceToVideoMixRequest;
|
||||
use fal_client::requests::api::video::text::vidu_q3::api::ViduQ3TextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalViduQ3Mode {
|
||||
TextToVideo(ViduQ3TextToVideoRequest),
|
||||
ImageToVideo(ViduQ3ImageToVideoRequest),
|
||||
ReferenceToVideo(ViduQ3ReferenceToVideoRequest),
|
||||
ReferenceToVideoMix(ViduQ3ReferenceToVideoMixRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalViduQ3RequestState {
|
||||
pub mode: FalViduQ3Mode,
|
||||
}
|
||||
|
||||
impl FalViduQ3RequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
match &self.mode {
|
||||
FalViduQ3Mode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalViduQ3Mode::ImageToVideo(request) => send_request(request, client).await,
|
||||
FalViduQ3Mode::ReferenceToVideo(request) => send_request(request, client).await,
|
||||
FalViduQ3Mode::ReferenceToVideoMix(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::image::vidu_q3::api::ViduQ3ImageToVideoResolution;
|
||||
use fal_client::requests::api::video::text::vidu_q3::api::{
|
||||
ViduQ3TextToVideoAspectRatio, ViduQ3TextToVideoResolution,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_text_to_video_webhook() {
|
||||
let state = FalViduQ3RequestState {
|
||||
mode: FalViduQ3Mode::TextToVideo(ViduQ3TextToVideoRequest {
|
||||
prompt: "a calm lake at sunrise".to_string(),
|
||||
duration: Some(5),
|
||||
seed: None,
|
||||
aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine),
|
||||
resolution: Some(ViduQ3TextToVideoResolution::SevenTwentyP),
|
||||
audio: Some(false),
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_image_to_video_webhook() {
|
||||
let state = FalViduQ3RequestState {
|
||||
mode: FalViduQ3Mode::ImageToVideo(ViduQ3ImageToVideoRequest {
|
||||
prompt: "the lake comes alive".to_string(),
|
||||
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
end_image_url: None,
|
||||
duration: Some(5),
|
||||
seed: None,
|
||||
resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP),
|
||||
audio: Some(false),
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
}
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
use fal_client::requests::api::video::image::vidu_q3_turbo::api::{
|
||||
ViduQ3TurboImageToVideoRequest, ViduQ3TurboImageToVideoResolution,
|
||||
};
|
||||
use fal_client::requests::api::video::text::vidu_q3_turbo::api::{
|
||||
ViduQ3TurboTextToVideoAspectRatio, ViduQ3TurboTextToVideoRequest, ViduQ3TurboTextToVideoResolution,
|
||||
};
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::{
|
||||
FalViduQ3TurboMode, FalViduQ3TurboRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
const MIN_DURATION_SECONDS: u16 = 1;
|
||||
const MAX_DURATION_SECONDS: u16 = 16;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanAspectRatio {
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
FourByThree,
|
||||
ThreeByFour,
|
||||
Square,
|
||||
}
|
||||
|
||||
/// Vidu also offers 360p, but no `RouterResolution` maps to it (fal bills 360p
|
||||
/// and 540p identically, so 540p is always the better pick).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PlanResolution {
|
||||
FiveFortyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
pub fn build_fal_vidu_q3_turbo(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_vidu_q3_turbo_state(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalViduQ3Turbo(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_vidu_q3_turbo_state(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<FalViduQ3TurboRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
reject_reference_videos(&builder.reference_videos)?;
|
||||
reject_reference_images(&builder.reference_images)?;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds, strategy)?;
|
||||
let prompt = builder.prompt.clone().unwrap_or_default();
|
||||
let audio = builder.generate_audio;
|
||||
let start_image_url = optional_url(builder.start_frame.clone())?;
|
||||
let end_image_url = optional_url(builder.end_frame.clone())?;
|
||||
|
||||
// Dispatch: start_frame → image; else → text. Turbo has no reference-to-video
|
||||
// endpoint (that's the non-turbo Vidu Q3 model).
|
||||
let mode = if let Some(image_url) = start_image_url {
|
||||
let i2v_resolution = resolution.map(to_i2v_resolution);
|
||||
check_i2v_end_frame_resolution(i2v_resolution, end_image_url.is_some())?;
|
||||
// Image-to-video has no aspect_ratio input — the output follows the start
|
||||
// frame — so any requested aspect ratio is silently dropped here.
|
||||
FalViduQ3TurboMode::ImageToVideo(ViduQ3TurboImageToVideoRequest {
|
||||
prompt,
|
||||
image_url,
|
||||
end_image_url,
|
||||
duration,
|
||||
seed: None,
|
||||
resolution: i2v_resolution,
|
||||
audio,
|
||||
})
|
||||
} else {
|
||||
if end_image_url.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Vidu Q3 Turbo requires a start_frame when end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
FalViduQ3TurboMode::TextToVideo(ViduQ3TurboTextToVideoRequest {
|
||||
prompt,
|
||||
duration,
|
||||
seed: None,
|
||||
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
|
||||
resolution: resolution.map(to_t2v_resolution),
|
||||
audio,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(FalViduQ3TurboRequestState { mode })
|
||||
}
|
||||
|
||||
// ── Input helpers ──
|
||||
|
||||
fn reject_reference_videos(
|
||||
reference_videos: &Option<VideoListRef>,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let has_reference_videos = match reference_videos {
|
||||
None => false,
|
||||
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
if has_reference_videos {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_videos",
|
||||
value: "Vidu Q3 Turbo does not support video references".to_string(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_reference_images(
|
||||
reference_images: &Option<ImageListRef>,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let has_reference_images = match reference_images {
|
||||
None => false,
|
||||
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
|
||||
};
|
||||
if has_reference_images {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Vidu Q3 Turbo does not support reference images (use Vidu Q3 for reference-to-video)".to_string(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// fal rejects 360p image-to-video requests that include an end frame.
|
||||
/// `plan_resolution` can't currently produce 360p, but guard anyway so a
|
||||
/// future mapping change can't silently ship the rejected combination.
|
||||
fn check_i2v_end_frame_resolution(
|
||||
resolution: Option<ViduQ3TurboImageToVideoResolution>,
|
||||
has_end_frame: bool,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
if has_end_frame && matches!(resolution, Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP)) {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: "Vidu Q3 Turbo image-to-video does not support 360p when an end_frame is provided".to_string(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
|
||||
use PlanAspectRatio as Ar;
|
||||
match aspect_ratio {
|
||||
None => Ok(None),
|
||||
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
|
||||
|
||||
Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", other),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(Ar::SixteenByNine)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Vidu doesn't offer 480p; 540p is the nearest supported size (and bills
|
||||
// at the same per-second rate as 360p, so nothing cheaper is lost).
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(PlanResolution::FiveFortyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)),
|
||||
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", other),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::FiveFortyP)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u8>, ArtcraftRouterError> {
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => {
|
||||
Ok(Some(seconds as u8))
|
||||
}
|
||||
Some(other) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaf converters ──
|
||||
|
||||
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> ViduQ3TurboTextToVideoAspectRatio {
|
||||
match a {
|
||||
PlanAspectRatio::SixteenByNine => ViduQ3TurboTextToVideoAspectRatio::SixteenByNine,
|
||||
PlanAspectRatio::NineBySixteen => ViduQ3TurboTextToVideoAspectRatio::NineBySixteen,
|
||||
PlanAspectRatio::FourByThree => ViduQ3TurboTextToVideoAspectRatio::FourByThree,
|
||||
PlanAspectRatio::ThreeByFour => ViduQ3TurboTextToVideoAspectRatio::ThreeByFour,
|
||||
PlanAspectRatio::Square => ViduQ3TurboTextToVideoAspectRatio::Square,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_t2v_resolution(r: PlanResolution) -> ViduQ3TurboTextToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::FiveFortyP => ViduQ3TurboTextToVideoResolution::FiveFortyP,
|
||||
PlanResolution::SevenTwentyP => ViduQ3TurboTextToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => ViduQ3TurboTextToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i2v_resolution(r: PlanResolution) -> ViduQ3TurboImageToVideoResolution {
|
||||
match r {
|
||||
PlanResolution::FiveFortyP => ViduQ3TurboImageToVideoResolution::FiveFortyP,
|
||||
PlanResolution::SevenTwentyP => ViduQ3TurboImageToVideoResolution::SevenTwentyP,
|
||||
PlanResolution::TenEightyP => ViduQ3TurboImageToVideoResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
const START_URL: &str = "https://example.com/start.png";
|
||||
const END_URL: &str = "https://example.com/end.png";
|
||||
|
||||
mod dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_inputs_picks_t2v() {
|
||||
let state = build_fal_vidu_q3_turbo_state(base_builder()).expect("build");
|
||||
assert!(matches!(state.mode, FalViduQ3TurboMode::TextToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_picks_i2v() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
let state = build_fal_vidu_q3_turbo_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalViduQ3TurboMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_end_frame_picks_i2v_with_end_image_url() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
let state = build_fal_vidu_q3_turbo_state(b).expect("build");
|
||||
let FalViduQ3TurboMode::ImageToVideo(req) = state.mode else {
|
||||
panic!("expected ImageToVideo");
|
||||
};
|
||||
assert_eq!(req.image_url, START_URL);
|
||||
assert_eq!(req.end_image_url.as_deref(), Some(END_URL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let mut b = base_builder();
|
||||
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
|
||||
assert!(build_fal_vidu_q3_turbo_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_error() {
|
||||
let mut b = base_builder();
|
||||
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
|
||||
assert!(build_fal_vidu_q3_turbo_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_videos_error() {
|
||||
let mut b = base_builder();
|
||||
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()]));
|
||||
assert!(build_fal_vidu_q3_turbo_state(b).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_20_errors_under_error_out() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(20);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_fal_vidu_q3_turbo_state(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_20_clamps_to_16_under_pay_less() {
|
||||
let mut b = base_builder();
|
||||
b.duration_seconds = Some(20);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
let state = build_fal_vidu_q3_turbo_state(b).expect("build");
|
||||
let FalViduQ3TurboMode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.duration, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_480p_maps_to_540p() {
|
||||
let mut b = base_builder();
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
let state = build_fal_vidu_q3_turbo_state(b).expect("build");
|
||||
let FalViduQ3TurboMode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.resolution, Some(ViduQ3TurboTextToVideoResolution::FiveFortyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_aspect_ratio_is_silently_dropped() {
|
||||
let mut b = base_builder();
|
||||
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
|
||||
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
// Image-to-video has no aspect_ratio field; the request still builds.
|
||||
let state = build_fal_vidu_q3_turbo_state(b).expect("build");
|
||||
assert!(matches!(state.mode, FalViduQ3TurboMode::ImageToVideo(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_360p_with_end_frame_is_rejected_by_guard() {
|
||||
// 360p is unreachable from RouterResolution today, so exercise the
|
||||
// guard directly against the fal-level resolution enum.
|
||||
let result = check_i2v_end_frame_resolution(
|
||||
Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP),
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(check_i2v_end_frame_resolution(Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP), false).is_ok());
|
||||
assert!(check_i2v_end_frame_resolution(Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_audio_propagates() {
|
||||
let mut b = base_builder();
|
||||
b.generate_audio = Some(false);
|
||||
let state = build_fal_vidu_q3_turbo_state(b).expect("build");
|
||||
let FalViduQ3TurboMode::TextToVideo(req) = state.mode else {
|
||||
panic!("expected TextToVideo");
|
||||
};
|
||||
assert_eq!(req.audio, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3Turbo,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::{
|
||||
FalViduQ3TurboMode, FalViduQ3TurboRequestState,
|
||||
};
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalViduQ3TurboCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalViduQ3TurboCostState {
|
||||
pub fn from_request(request: &FalViduQ3TurboRequestState) -> Self {
|
||||
// Cost math is owned by fal_client's per-endpoint
|
||||
// `FalRequestCostCalculator` implementations. The router state just
|
||||
// forwards the result so router cost ≡ fal_client cost by construction.
|
||||
let cost_in_usd_cents = match &request.mode {
|
||||
FalViduQ3TurboMode::TextToVideo(req) => req.calculate_cost_in_cents(),
|
||||
FalViduQ3TurboMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
|
||||
};
|
||||
Self { cost_in_usd_cents }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(self.cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(self.cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo_state;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Pricing (from fal_client's vidu_q3_turbo cost modules; half the Vidu Q3 rate):
|
||||
// 360p/540p: $0.035/sec → 5s = 17.5¢ → 18¢
|
||||
// 720p/1080p: $0.077/sec → 5s = 38.5¢ → 39¢, 10s = 77¢ (ceil to whole cents)
|
||||
// fal defaults when unset: duration = 5s, resolution = 720p.
|
||||
// Both modes (text/image) bill identically.
|
||||
//
|
||||
// NB: `build2()` isn't wired up for Vidu Q3 Turbo yet, so these tests go
|
||||
// through `build_fal_vidu_q3_turbo_state()` directly.
|
||||
|
||||
#[test]
|
||||
fn t2v_default_settings_is_39() {
|
||||
assert_eq!(cost_cents(base_builder(None, None)), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_5s_720p_rounds_up_to_39() {
|
||||
// 5 × 7.7¢ = 38.5¢ → 39¢.
|
||||
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_5s_540p_rounds_up_to_18() {
|
||||
// RouterResolution::FourEightyP maps to Vidu's 540p; 5 × 3.5¢ = 17.5¢ → 18¢.
|
||||
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::FourEightyP))), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_10s_1080p_is_77() {
|
||||
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2v_3s_720p_rounds_up_to_24() {
|
||||
// 3 × 7.7¢ = 23.1¢ → 24¢.
|
||||
assert_eq!(cost_cents(base_builder(Some(3), Some(RouterResolution::SevenTwentyP))), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_5s_720p_is_39() {
|
||||
let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP));
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
assert_eq!(cost_cents(b), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_10s_540p_is_35() {
|
||||
let mut b = base_builder(Some(10), Some(RouterResolution::FourEightyP));
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
assert_eq!(cost_cents(b), 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_cost_forwards_cents() {
|
||||
let estimate = FalViduQ3TurboCostState { cost_in_usd_cents: 39 }.estimate_cost();
|
||||
assert_eq!(estimate.cost_in_usd_cents, Some(39));
|
||||
assert_eq!(estimate.cost_in_credits, Some(39));
|
||||
assert!(!estimate.is_free);
|
||||
}
|
||||
|
||||
fn base_builder(
|
||||
duration_seconds: Option<u16>,
|
||||
resolution: Option<RouterResolution>,
|
||||
) -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::ViduQ3Turbo,
|
||||
provider: RouterProvider::Fal,
|
||||
prompt: Some("test".to_string()),
|
||||
duration_seconds,
|
||||
resolution,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
|
||||
let state = build_fal_vidu_q3_turbo_state(builder).expect("build");
|
||||
FalViduQ3TurboCostState::from_request(&state).cost_in_usd_cents
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fal_client::requests::api::video::image::vidu_q3_turbo::api::ViduQ3TurboImageToVideoRequest;
|
||||
use fal_client::requests::api::video::text::vidu_q3_turbo::api::ViduQ3TurboTextToVideoRequest;
|
||||
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
FalVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FalViduQ3TurboMode {
|
||||
TextToVideo(ViduQ3TurboTextToVideoRequest),
|
||||
ImageToVideo(ViduQ3TurboImageToVideoRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalViduQ3TurboRequestState {
|
||||
pub mode: FalViduQ3TurboMode,
|
||||
}
|
||||
|
||||
impl FalViduQ3TurboRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
match &self.mode {
|
||||
FalViduQ3TurboMode::TextToVideo(request) => send_request(request, client).await,
|
||||
FalViduQ3TurboMode::ImageToVideo(request) => send_request(request, client).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
|
||||
where
|
||||
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
|
||||
{
|
||||
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
|
||||
let payload = send_fal_request(request, client).await?;
|
||||
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
|
||||
request_id: payload.request_id,
|
||||
gateway_request_id: payload.gateway_request_id,
|
||||
maybe_status_url: payload.status_url,
|
||||
maybe_response_url: payload.response_url,
|
||||
maybe_outbound_request: Some(outbound),
|
||||
}))
|
||||
}
|
||||
|
||||
struct FalResponseIds {
|
||||
request_id: Option<String>,
|
||||
gateway_request_id: Option<String>,
|
||||
status_url: Option<String>,
|
||||
response_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn send_fal_request<T: FalEndpoint>(
|
||||
request: &T,
|
||||
client: &RouterFalClient,
|
||||
) -> Result<FalResponseIds, ArtcraftRouterError> {
|
||||
if let Some(webhook_url) = &client.webhook_url {
|
||||
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: response.request_id,
|
||||
gateway_request_id: response.gateway_request_id,
|
||||
status_url: None,
|
||||
response_url: None,
|
||||
})
|
||||
} else {
|
||||
let response = request.send_queue_request(&client.api_key).await?;
|
||||
Ok(FalResponseIds {
|
||||
request_id: Some(response.request_id),
|
||||
gateway_request_id: None,
|
||||
status_url: Some(response.status_url),
|
||||
response_url: Some(response.response_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fal_client::creds::fal_api_key::FalApiKey;
|
||||
use fal_client::requests::api::video::image::vidu_q3_turbo::api::ViduQ3TurboImageToVideoResolution;
|
||||
use fal_client::requests::api::video::text::vidu_q3_turbo::api::{
|
||||
ViduQ3TurboTextToVideoAspectRatio, ViduQ3TurboTextToVideoResolution,
|
||||
};
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
fn read_fal_api_key() -> FalApiKey {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
|
||||
.expect("Failed to read fal_api_key.txt");
|
||||
FalApiKey::from_str(secret.trim())
|
||||
}
|
||||
|
||||
fn client_with_webhook() -> RouterFalClient {
|
||||
RouterFalClient::new_with_webhook(
|
||||
read_fal_api_key(),
|
||||
"https://example.com/fal-webhook-test".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_text_to_video_webhook() {
|
||||
let state = FalViduQ3TurboRequestState {
|
||||
mode: FalViduQ3TurboMode::TextToVideo(ViduQ3TurboTextToVideoRequest {
|
||||
prompt: "a calm lake at sunrise".to_string(),
|
||||
duration: Some(5),
|
||||
seed: None,
|
||||
aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine),
|
||||
resolution: Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP),
|
||||
audio: Some(false),
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_image_to_video_webhook() {
|
||||
let state = FalViduQ3TurboRequestState {
|
||||
mode: FalViduQ3TurboMode::ImageToVideo(ViduQ3TurboImageToVideoRequest {
|
||||
prompt: "the lake comes alive".to_string(),
|
||||
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
end_image_url: None,
|
||||
duration: Some(5),
|
||||
seed: None,
|
||||
resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP),
|
||||
audio: Some(false),
|
||||
}),
|
||||
};
|
||||
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
|
||||
let payload = response.get_fal_payload().expect("expected Fal payload");
|
||||
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
|
||||
}
|
||||
}
|
||||
+54
@@ -67,6 +67,12 @@ use crate::generate::generate_video::providers::artcraft::veo_3p1::cost::Artcraf
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::cost::ArtcraftVeo3p1FastCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::cost::ArtcraftVeo3p1LiteCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::cost::ArtcraftViduQ3CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::cost::ArtcraftViduQ3TurboCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::cost::FalKling16ProCostState;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::request::FalKling16ProRequestState;
|
||||
use crate::generate::generate_video::providers::fal::kling_2_1_master::cost::FalKling21MasterCostState;
|
||||
@@ -99,6 +105,12 @@ use crate::generate::generate_video::providers::fal::veo_3p1::cost::FalVeo3p1Cos
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1::request::FalVeo3p1RequestState;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_fast::cost::FalVeo3p1FastCostState;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_fast::request::FalVeo3p1FastRequestState;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::cost::FalVeo3p1LiteCostState;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::FalVeo3p1LiteRequestState;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::cost::FalViduQ3CostState;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::request::FalViduQ3RequestState;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::cost::FalViduQ3TurboCostState;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::FalViduQ3TurboRequestState;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0UltraCostState;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState;
|
||||
@@ -150,6 +162,9 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftVeo3Fast(ArtcraftVeo3FastRequestState),
|
||||
ArtcraftVeo3p1(ArtcraftVeo3p1RequestState),
|
||||
ArtcraftVeo3p1Fast(ArtcraftVeo3p1FastRequestState),
|
||||
ArtcraftVeo3p1Lite(ArtcraftVeo3p1LiteRequestState),
|
||||
ArtcraftViduQ3(ArtcraftViduQ3RequestState),
|
||||
ArtcraftViduQ3Turbo(ArtcraftViduQ3TurboRequestState),
|
||||
FalKling16Pro(FalKling16ProRequestState),
|
||||
FalKling21Master(FalKling21MasterRequestState),
|
||||
FalKling21Pro(FalKling21ProRequestState),
|
||||
@@ -166,6 +181,9 @@ pub enum VideoGenerationRequest {
|
||||
FalVeo3Fast(FalVeo3FastRequestState),
|
||||
FalVeo3p1(FalVeo3p1RequestState),
|
||||
FalVeo3p1Fast(FalVeo3p1FastRequestState),
|
||||
FalVeo3p1Lite(FalVeo3p1LiteRequestState),
|
||||
FalViduQ3(FalViduQ3RequestState),
|
||||
FalViduQ3Turbo(FalViduQ3TurboRequestState),
|
||||
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
|
||||
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
|
||||
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
|
||||
@@ -212,6 +230,9 @@ impl VideoGenerationRequest {
|
||||
Self::ArtcraftVeo3Fast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftVeo3p1(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftVeo3p1Fast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftVeo3p1Lite(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftViduQ3(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftViduQ3Turbo(_) => RouterProvider::Artcraft,
|
||||
Self::FalKling16Pro(_) => RouterProvider::Fal,
|
||||
Self::FalKling21Master(_) => RouterProvider::Fal,
|
||||
Self::FalKling21Pro(_) => RouterProvider::Fal,
|
||||
@@ -228,6 +249,9 @@ impl VideoGenerationRequest {
|
||||
Self::FalVeo3Fast(_) => RouterProvider::Fal,
|
||||
Self::FalVeo3p1(_) => RouterProvider::Fal,
|
||||
Self::FalVeo3p1Fast(_) => RouterProvider::Fal,
|
||||
Self::FalVeo3p1Lite(_) => RouterProvider::Fal,
|
||||
Self::FalViduQ3(_) => RouterProvider::Fal,
|
||||
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
|
||||
Self::GmiCloudSeedance2p0Ultra(_) => RouterProvider::GmiCloud,
|
||||
Self::GmiCloudSeedance2p0UltraFast(_) => RouterProvider::GmiCloud,
|
||||
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
|
||||
@@ -274,6 +298,9 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::ArtcraftVeo3Fast(request) => Ok(ArtcraftVeo3FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftVeo3p1(request) => Ok(ArtcraftVeo3p1CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftVeo3p1Fast(request) => Ok(ArtcraftVeo3p1FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftVeo3p1Lite(request) => Ok(ArtcraftVeo3p1LiteCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftViduQ3(request) => Ok(ArtcraftViduQ3CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftViduQ3Turbo(request) => Ok(ArtcraftViduQ3TurboCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalKling16Pro(request) => Ok(FalKling16ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalKling21Master(request) => Ok(FalKling21MasterCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalKling21Pro(request) => Ok(FalKling21ProCostState::from_request(request).estimate_cost()),
|
||||
@@ -290,6 +317,9 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::FalVeo3Fast(request) => Ok(FalVeo3FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalVeo3p1(request) => Ok(FalVeo3p1CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalVeo3p1Fast(request) => Ok(FalVeo3p1FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalVeo3p1Lite(request) => Ok(FalVeo3p1LiteCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalViduQ3(request) => Ok(FalViduQ3CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
|
||||
@@ -433,6 +463,18 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftVeo3p1Lite(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftViduQ3(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftViduQ3Turbo(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::FalKling16Pro(request) => {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -497,6 +539,18 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::FalVeo3p1Lite(request) => {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::FalViduQ3(request) => {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::FalViduQ3Turbo(request) => {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => {
|
||||
let client_ref = client.get_gmicloud_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-music-generation";
|
||||
const MODEL: &str = "suno-music";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 16;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoMusicArgs<'a> {
|
||||
pub request: GenerateSunoMusicRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Music: text-to-music generation.
|
||||
///
|
||||
/// NB: the Kinovi UI offers a voice choice (any / male / female), but it is
|
||||
/// never transmitted on the wire — steer the vocalist through the prompt or
|
||||
/// style tags instead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoMusicRequest {
|
||||
/// What the song should be about.
|
||||
pub prompt: String,
|
||||
|
||||
/// Optional style/genre direction (the UI's "style prompt"), e.g.
|
||||
/// "EDM style meets dance".
|
||||
pub style_tags: Option<String>,
|
||||
|
||||
/// Generate an instrumental-only track (no vocals).
|
||||
pub instrumental: bool,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Music: flat 16 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoMusicRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoMusicResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_music(
|
||||
args: GenerateSunoMusicArgs<'_>,
|
||||
) -> Result<GenerateSunoMusicResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoMusicResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-music-generation`. Field order matches the
|
||||
/// captured browser traffic.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoMusicApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
custom: bool,
|
||||
instrumental: bool,
|
||||
mv: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<String>,
|
||||
#[serde(rename = "generateVideo")]
|
||||
generate_video: bool,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoMusicRequest) -> SunoMusicApiParams {
|
||||
SunoMusicApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
custom: false,
|
||||
instrumental: request.instrumental,
|
||||
mv: MODEL_VERSION,
|
||||
tags: request.style_tags.clone(),
|
||||
generate_video: false,
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoMusicRequest {
|
||||
GenerateSunoMusicRequest {
|
||||
prompt: "A song".to_string(),
|
||||
style_tags: None,
|
||||
instrumental: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_sixteen_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 7);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 6);
|
||||
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let styled = GenerateSunoMusicRequest {
|
||||
prompt: "Another song".to_string(),
|
||||
style_tags: Some("EDM".to_string()),
|
||||
instrumental: true,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, styled);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 1_suno_music.txt (no style tags, with vocals).
|
||||
#[test]
|
||||
fn basic_prompt() {
|
||||
let params = build_api_params(&GenerateSunoMusicRequest {
|
||||
prompt: "A song about corgi super heroes fighting shiba super villains.".to_string(),
|
||||
style_tags: None,
|
||||
instrumental: false,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-music","prompt":"A song about corgi super heroes fighting shiba super villains.","custom":false,"instrumental":false,"mv":"chirp-v5-5","generateVideo":false,"isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 2_suno_music_style_female.txt (style tags present).
|
||||
#[test]
|
||||
fn with_style_tags() {
|
||||
let params = build_api_params(&GenerateSunoMusicRequest {
|
||||
prompt: "A song about Mario and Luigi fighting Bowser".to_string(),
|
||||
style_tags: Some("EDM style meets dance".to_string()),
|
||||
instrumental: false,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-music","prompt":"A song about Mario and Luigi fighting Bowser","custom":false,"instrumental":false,"mv":"chirp-v5-5","tags":"EDM style meets dance","generateVideo":false,"isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 3_suno_music_instrumental.txt.
|
||||
#[test]
|
||||
fn instrumental() {
|
||||
let params = build_api_params(&GenerateSunoMusicRequest {
|
||||
prompt: "An epic journey for a JRPG".to_string(),
|
||||
style_tags: Some("Japanese JRPG adventure music".to_string()),
|
||||
instrumental: true,
|
||||
});
|
||||
let json = serde_json::to_string(¶ms).unwrap();
|
||||
assert!(json.contains(r#""instrumental":true"#), "{json}");
|
||||
assert!(json.contains(r#""custom":false"#), "{json}");
|
||||
assert!(json.contains(r#""mv":"chirp-v5-5""#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-music-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_music_with_vocals() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_music(GenerateSunoMusicArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoMusicRequest {
|
||||
prompt: "A song about a corgi who learns to sail the open sea".to_string(),
|
||||
style_tags: Some("Sea shanty, folk".to_string()),
|
||||
instrumental: false,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno music — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_music_instrumental() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_music(GenerateSunoMusicArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoMusicRequest {
|
||||
prompt: "An epic journey across a frozen mountain range".to_string(),
|
||||
style_tags: Some("Cinematic orchestral score".to_string()),
|
||||
instrumental: true,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno music instrumental — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-remix-generation";
|
||||
const MODEL: &str = "suno-remix";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 16;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoRemixArgs<'a> {
|
||||
pub request: GenerateSunoRemixRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Remix: transform an existing piece of audio.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoRemixRequest {
|
||||
/// How to transform the audio, e.g. "Make this electronic".
|
||||
pub prompt: String,
|
||||
|
||||
/// The audio to remix.
|
||||
pub source: KinoviSunoRemixSource,
|
||||
|
||||
/// Optional style/genre direction (the UI's "style prompt").
|
||||
pub style_tags: Option<String>,
|
||||
|
||||
/// Keep the original lyrics in the remix.
|
||||
pub keep_lyrics: bool,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
/// The audio input for a remix: either an uploaded file (by CDN URL) or a
|
||||
/// previously generated Suno track (by its music id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum KinoviSunoRemixSource {
|
||||
/// A Kinovi CDN URL to an uploaded audio file.
|
||||
AudioUrl(String),
|
||||
/// The id of a previously generated Suno track
|
||||
/// (e.g. "fea7e70c-5373-452d-9c5e-f079ff837fcd").
|
||||
MusicId(String),
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Remix: flat 16 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoRemixRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoRemixResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_remix(
|
||||
args: GenerateSunoRemixArgs<'_>,
|
||||
) -> Result<GenerateSunoRemixResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoRemixResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-remix-generation`. Field order matches the
|
||||
/// captured browser traffic. Exactly one of `audio_url` / `music_id` is set.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoRemixApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
#[serde(rename = "audioUrl", skip_serializing_if = "Option::is_none")]
|
||||
audio_url: Option<String>,
|
||||
#[serde(rename = "musicId", skip_serializing_if = "Option::is_none")]
|
||||
music_id: Option<String>,
|
||||
custom: bool,
|
||||
#[serde(rename = "keepLyrics")]
|
||||
keep_lyrics: bool,
|
||||
mv: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<String>,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoRemixRequest) -> SunoRemixApiParams {
|
||||
let (audio_url, music_id) = match &request.source {
|
||||
KinoviSunoRemixSource::AudioUrl(url) => (Some(url.clone()), None),
|
||||
KinoviSunoRemixSource::MusicId(id) => (None, Some(id.clone())),
|
||||
};
|
||||
|
||||
SunoRemixApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
audio_url,
|
||||
music_id,
|
||||
custom: false,
|
||||
keep_lyrics: request.keep_lyrics,
|
||||
mv: MODEL_VERSION,
|
||||
tags: request.style_tags.clone(),
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoRemixRequest {
|
||||
GenerateSunoRemixRequest {
|
||||
prompt: "Make this electronic".to_string(),
|
||||
source: KinoviSunoRemixSource::AudioUrl("https://example.com/a.mp3".to_string()),
|
||||
style_tags: None,
|
||||
keep_lyrics: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_sixteen_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 7);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 6);
|
||||
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let other = GenerateSunoRemixRequest {
|
||||
prompt: "Make this a symphony".to_string(),
|
||||
source: KinoviSunoRemixSource::MusicId("abc".to_string()),
|
||||
style_tags: Some("Classical music".to_string()),
|
||||
keep_lyrics: true,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, other);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 5_suno_remix.txt (uploaded audio URL, no lyric keep).
|
||||
#[test]
|
||||
fn audio_url_source() {
|
||||
let params = build_api_params(&GenerateSunoRemixRequest {
|
||||
prompt: "Make this electronic".to_string(),
|
||||
source: KinoviSunoRemixSource::AudioUrl(
|
||||
"https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3".to_string(),
|
||||
),
|
||||
style_tags: Some("EDM style".to_string()),
|
||||
keep_lyrics: false,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-remix","prompt":"Make this electronic","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783399892833-7d9668fb.mp3","custom":false,"keepLyrics":false,"mv":"chirp-v5-5","tags":"EDM style","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 6_suno_remix_lyrics.txt (existing track by music id,
|
||||
/// keeping the lyrics).
|
||||
#[test]
|
||||
fn music_id_source_with_kept_lyrics() {
|
||||
let params = build_api_params(&GenerateSunoRemixRequest {
|
||||
prompt: "Make this a symphony".to_string(),
|
||||
source: KinoviSunoRemixSource::MusicId("fea7e70c-5373-452d-9c5e-f079ff837fcd".to_string()),
|
||||
style_tags: Some("Classical music".to_string()),
|
||||
keep_lyrics: true,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-remix","prompt":"Make this a symphony","musicId":"fea7e70c-5373-452d-9c5e-f079ff837fcd","custom":false,"keepLyrics":true,"mv":"chirp-v5-5","tags":"Classical music","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-remix-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
|
||||
async fn test_remix_uploaded_audio() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let audio_url = upload_test_audio(&session).await?;
|
||||
println!("Uploaded audio: {}", audio_url);
|
||||
|
||||
let result = generate_suno_remix(GenerateSunoRemixArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoRemixRequest {
|
||||
prompt: "Make this electronic".to_string(),
|
||||
source: KinoviSunoRemixSource::AudioUrl(audio_url),
|
||||
style_tags: Some("EDM style".to_string()),
|
||||
keep_lyrics: false,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno remix — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_test_audio(session: &Seedance2ProSession) -> AnyhowResult<String> {
|
||||
let audio_path = test_utils::test_file_path::test_file_path(
|
||||
"test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3",
|
||||
)?;
|
||||
let audio_bytes = std::fs::read(&audio_path)?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: "mp3".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: audio_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-sample-generation";
|
||||
const MODEL: &str = "suno-sample";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 16;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoSampleArgs<'a> {
|
||||
pub request: GenerateSunoSampleRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Sample: build a track from a chopped sample of an uploaded audio file.
|
||||
///
|
||||
/// NB: the Kinovi UI offers a voice choice (male / female / no voice), but it
|
||||
/// is never transmitted on the wire — `instrumental` controls whether vocals
|
||||
/// are added at all; steer the vocalist through the prompt or style tags.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoSampleRequest {
|
||||
/// What to build from the sample, e.g. "Mystical RPG adventure, make it
|
||||
/// have a grand climax".
|
||||
pub prompt: String,
|
||||
|
||||
/// A Kinovi CDN URL to the uploaded audio file to sample.
|
||||
pub audio_url: String,
|
||||
|
||||
/// Start of the sample window within the audio, in seconds.
|
||||
pub chop_sample_start_seconds: u32,
|
||||
|
||||
/// End of the sample window within the audio, in seconds.
|
||||
pub chop_sample_end_seconds: u32,
|
||||
|
||||
/// Optional style/genre direction (the UI's "style prompt").
|
||||
pub style_tags: Option<String>,
|
||||
|
||||
/// Generate an instrumental-only track (no vocals).
|
||||
pub instrumental: bool,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Sample: flat 16 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoSampleRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoSampleResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_sample(
|
||||
args: GenerateSunoSampleArgs<'_>,
|
||||
) -> Result<GenerateSunoSampleResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoSampleResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-sample-generation`. Field order matches the
|
||||
/// captured browser traffic.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoSampleApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
#[serde(rename = "audioUrl")]
|
||||
audio_url: String,
|
||||
#[serde(rename = "chopSampleStartS")]
|
||||
chop_sample_start_s: u32,
|
||||
#[serde(rename = "chopSampleEndS")]
|
||||
chop_sample_end_s: u32,
|
||||
custom: bool,
|
||||
instrumental: bool,
|
||||
#[serde(rename = "autoLyrics")]
|
||||
auto_lyrics: bool,
|
||||
mv: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<String>,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoSampleRequest) -> SunoSampleApiParams {
|
||||
SunoSampleApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
audio_url: request.audio_url.clone(),
|
||||
chop_sample_start_s: request.chop_sample_start_seconds,
|
||||
chop_sample_end_s: request.chop_sample_end_seconds,
|
||||
custom: false,
|
||||
instrumental: request.instrumental,
|
||||
auto_lyrics: true,
|
||||
mv: MODEL_VERSION,
|
||||
tags: request.style_tags.clone(),
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoSampleRequest {
|
||||
GenerateSunoSampleRequest {
|
||||
prompt: "Mystical RPG adventure".to_string(),
|
||||
audio_url: "https://example.com/a.aac".to_string(),
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 41,
|
||||
style_tags: None,
|
||||
instrumental: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_sixteen_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 16 credits; 1600/243 = 6.5843… → up 7¢, down 6¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 7);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 6);
|
||||
assert!((costs.usd_cents_fractional - (1600.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let other = GenerateSunoSampleRequest {
|
||||
prompt: "A vocal fantasy track".to_string(),
|
||||
audio_url: "https://example.com/b.aac".to_string(),
|
||||
chop_sample_start_seconds: 10,
|
||||
chop_sample_end_seconds: 30,
|
||||
style_tags: Some("Fantasy video game".to_string()),
|
||||
instrumental: false,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, other);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 11_suno_sample_1.txt (instrumental).
|
||||
#[test]
|
||||
fn instrumental_sample() {
|
||||
let params = build_api_params(&GenerateSunoSampleRequest {
|
||||
prompt: "Mystical RPG adventure, make it have a grand climax".to_string(),
|
||||
audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(),
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 41,
|
||||
style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()),
|
||||
instrumental: true,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sample","prompt":"Mystical RPG adventure, make it have a grand climax","audioUrl":"https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac","chopSampleStartS":0,"chopSampleEndS":41,"custom":false,"instrumental":true,"autoLyrics":true,"mv":"chirp-v5-5","tags":"Fantasy video game, Motoi Sakuraba, Golden Sun","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 12_suno_sample_2.txt (with vocals).
|
||||
#[test]
|
||||
fn vocal_sample() {
|
||||
let params = build_api_params(&GenerateSunoSampleRequest {
|
||||
prompt: "Give this a lyrics track, a woman singing in a fantasy language".to_string(),
|
||||
audio_url: "https://static.seedance2-pro.com/materials/20260707/1783401649804-e654f994.aac".to_string(),
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 41,
|
||||
style_tags: Some("Fantasy video game, Motoi Sakuraba, Golden Sun".to_string()),
|
||||
instrumental: false,
|
||||
});
|
||||
let json = serde_json::to_string(¶ms).unwrap();
|
||||
assert!(json.contains(r#""instrumental":false"#), "{json}");
|
||||
assert!(json.contains(r#""autoLyrics":true"#), "{json}");
|
||||
assert!(json.contains(r#""chopSampleStartS":0"#), "{json}");
|
||||
assert!(json.contains(r#""chopSampleEndS":41"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-sample-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
|
||||
async fn test_sample_instrumental() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let audio_url = upload_test_audio(&session).await?;
|
||||
println!("Uploaded audio: {}", audio_url);
|
||||
|
||||
let result = generate_suno_sample(GenerateSunoSampleArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoSampleRequest {
|
||||
prompt: "Mystical RPG adventure, make it have a grand climax".to_string(),
|
||||
audio_url,
|
||||
chop_sample_start_seconds: 0,
|
||||
chop_sample_end_seconds: 30,
|
||||
style_tags: Some("Fantasy video game score".to_string()),
|
||||
instrumental: true,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno sample — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_test_audio(session: &Seedance2ProSession) -> AnyhowResult<String> {
|
||||
let audio_path = test_utils::test_file_path::test_file_path(
|
||||
"test_data/audio/aac/golden_sun_elemental_stars_cyanne.aac",
|
||||
)?;
|
||||
let audio_bytes = std::fs::read(&audio_path)?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: "aac".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: audio_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task_custom, WorkflowRunTaskCustomArgs, WorkflowRunTaskResponse,
|
||||
};
|
||||
|
||||
const BUSINESS_TYPE: &str = "suno-sounds-generation";
|
||||
const MODEL: &str = "suno-sounds";
|
||||
const MODEL_VERSION: &str = "chirp-v5-5";
|
||||
|
||||
/// Flat credit price per generation.
|
||||
const CREDITS_PER_GENERATION: u64 = 4;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSunoSoundArgs<'a> {
|
||||
pub request: GenerateSunoSoundRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Suno Sounds: short sound-effect / sample generation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSunoSoundRequest {
|
||||
/// What the sound should be, e.g. "Rain sound effects".
|
||||
pub prompt: String,
|
||||
|
||||
/// Single hit vs seamless loop.
|
||||
pub sound_type: KinoviSunoSoundType,
|
||||
|
||||
/// Optional tempo in BPM.
|
||||
pub bpm: Option<u16>,
|
||||
|
||||
/// Musical key. `Auto` lets Kinovi pick.
|
||||
pub key: KinoviSunoSoundKey,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
/// Single hit ("one-shot") vs seamless loop ("loop").
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSunoSoundType {
|
||||
SingleHit,
|
||||
Loopable,
|
||||
}
|
||||
|
||||
/// Musical key for the generated sound.
|
||||
///
|
||||
/// NB: the Kinovi UI's "Auto" selection still transmits "C" on the wire
|
||||
/// (observed in captured traffic), so `Auto` and `CMajor` are wire-identical.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSunoSoundKey {
|
||||
Auto,
|
||||
CMajor,
|
||||
CMinor,
|
||||
DMajor,
|
||||
DMinor,
|
||||
FMajor,
|
||||
FMinor,
|
||||
GMajor,
|
||||
GMinor,
|
||||
AMajor,
|
||||
AMinor,
|
||||
BMajor,
|
||||
BMinor,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Suno Sounds: flat 4 Kinovi credits per generation, regardless of options.
|
||||
|
||||
impl GenerateSunoSoundRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
KinoviGenerationCost::from_kinovi_credits(CREDITS_PER_GENERATION)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSunoSoundResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_suno_sound(
|
||||
args: GenerateSunoSoundArgs<'_>,
|
||||
) -> Result<GenerateSunoSoundResponse, Seedance2ProError> {
|
||||
let raw_response: WorkflowRunTaskResponse = workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: build_api_params(&args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSunoSoundResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire payload ──
|
||||
|
||||
/// The `apiParams` shape for `suno-sounds-generation`. Field order matches the
|
||||
/// captured browser traffic.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SunoSoundApiParams {
|
||||
model: &'static str,
|
||||
prompt: String,
|
||||
mv: &'static str,
|
||||
#[serde(rename = "type")]
|
||||
sound_type: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
bpm: Option<u16>,
|
||||
key: &'static str,
|
||||
#[serde(rename = "isStorage")]
|
||||
is_storage: bool,
|
||||
}
|
||||
|
||||
fn build_api_params(request: &GenerateSunoSoundRequest) -> SunoSoundApiParams {
|
||||
SunoSoundApiParams {
|
||||
model: MODEL,
|
||||
prompt: request.prompt.clone(),
|
||||
mv: MODEL_VERSION,
|
||||
sound_type: sound_type_as_api_str(request.sound_type),
|
||||
bpm: request.bpm,
|
||||
key: key_as_api_str(request.key),
|
||||
is_storage: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn sound_type_as_api_str(sound_type: KinoviSunoSoundType) -> &'static str {
|
||||
match sound_type {
|
||||
KinoviSunoSoundType::SingleHit => "one-shot",
|
||||
KinoviSunoSoundType::Loopable => "loop",
|
||||
}
|
||||
}
|
||||
|
||||
fn key_as_api_str(key: KinoviSunoSoundKey) -> &'static str {
|
||||
match key {
|
||||
// The UI's Auto choice sends "C" on the wire.
|
||||
KinoviSunoSoundKey::Auto => "C",
|
||||
KinoviSunoSoundKey::CMajor => "C",
|
||||
KinoviSunoSoundKey::CMinor => "Cm",
|
||||
KinoviSunoSoundKey::DMajor => "D",
|
||||
KinoviSunoSoundKey::DMinor => "Dm",
|
||||
KinoviSunoSoundKey::FMajor => "F",
|
||||
KinoviSunoSoundKey::FMinor => "Fm",
|
||||
KinoviSunoSoundKey::GMajor => "G",
|
||||
KinoviSunoSoundKey::GMinor => "Gm",
|
||||
KinoviSunoSoundKey::AMajor => "A",
|
||||
KinoviSunoSoundKey::AMinor => "Am",
|
||||
KinoviSunoSoundKey::BMajor => "B",
|
||||
KinoviSunoSoundKey::BMinor => "Bm",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> GenerateSunoSoundRequest {
|
||||
GenerateSunoSoundRequest {
|
||||
prompt: "Rain sound effects".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: None,
|
||||
key: KinoviSunoSoundKey::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_four_credits() {
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents() {
|
||||
// 4 credits; 400/243 = 1.6461… → up 2¢, down 1¢.
|
||||
let costs = base_request().calculate_costs();
|
||||
assert_eq!(costs.usd_cents_rounded_up, 2);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 1);
|
||||
assert!((costs.usd_cents_fractional - (400.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_affect_cost() {
|
||||
let base = base_request().calculate_costs();
|
||||
let other = GenerateSunoSoundRequest {
|
||||
prompt: "Thunder".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(180),
|
||||
key: KinoviSunoSoundKey::DMinor,
|
||||
}.calculate_costs();
|
||||
assert_eq!(base, other);
|
||||
}
|
||||
}
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors capture 7_suno_sounds_1.txt (single hit, no BPM, auto key —
|
||||
/// which transmits "C").
|
||||
#[test]
|
||||
fn single_hit_no_bpm_auto_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Rain sound effects".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: None,
|
||||
key: KinoviSunoSoundKey::Auto,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Rain sound effects","mv":"chirp-v5-5","type":"one-shot","key":"C","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 8_suno_sounds_2.txt (loop, 60 BPM, D minor).
|
||||
#[test]
|
||||
fn loop_with_bpm_and_minor_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Thunder".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(60),
|
||||
key: KinoviSunoSoundKey::DMinor,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Thunder","mv":"chirp-v5-5","type":"loop","bpm":60,"key":"Dm","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 9_suno_sounds_3.txt (loop, 180 BPM, F major).
|
||||
#[test]
|
||||
fn loop_with_major_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Plates crashing in the kitchen".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(180),
|
||||
key: KinoviSunoSoundKey::FMajor,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Plates crashing in the kitchen","mv":"chirp-v5-5","type":"loop","bpm":180,"key":"F","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors capture 10_suno_sounds_4.txt (single hit, 100 BPM, C minor).
|
||||
#[test]
|
||||
fn single_hit_with_bpm_and_minor_key() {
|
||||
let params = build_api_params(&GenerateSunoSoundRequest {
|
||||
prompt: "Car crash".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: Some(100),
|
||||
key: KinoviSunoSoundKey::CMinor,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_string(¶ms).unwrap(),
|
||||
r#"{"model":"suno-sounds","prompt":"Car crash","mv":"chirp-v5-5","type":"one-shot","bpm":100,"key":"Cm","isStorage":true}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_key_wire_value() {
|
||||
let cases = [
|
||||
(KinoviSunoSoundKey::Auto, "C"),
|
||||
(KinoviSunoSoundKey::CMajor, "C"),
|
||||
(KinoviSunoSoundKey::CMinor, "Cm"),
|
||||
(KinoviSunoSoundKey::DMajor, "D"),
|
||||
(KinoviSunoSoundKey::DMinor, "Dm"),
|
||||
(KinoviSunoSoundKey::FMajor, "F"),
|
||||
(KinoviSunoSoundKey::FMinor, "Fm"),
|
||||
(KinoviSunoSoundKey::GMajor, "G"),
|
||||
(KinoviSunoSoundKey::GMinor, "Gm"),
|
||||
(KinoviSunoSoundKey::AMajor, "A"),
|
||||
(KinoviSunoSoundKey::AMinor, "Am"),
|
||||
(KinoviSunoSoundKey::BMajor, "B"),
|
||||
(KinoviSunoSoundKey::BMinor, "Bm"),
|
||||
];
|
||||
for (key, expected) in cases {
|
||||
assert_eq!(key_as_api_str(key), expected, "wire value for {key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_type() {
|
||||
assert_eq!(BUSINESS_TYPE, "suno-sounds-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_single_hit() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_sound(GenerateSunoSoundArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoSoundRequest {
|
||||
prompt: "A heavy castle door creaking open".to_string(),
|
||||
sound_type: KinoviSunoSoundType::SingleHit,
|
||||
bpm: None,
|
||||
key: KinoviSunoSoundKey::Auto,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno sound single hit — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Sends a real generation to Kinovi; costs credits.
|
||||
async fn test_loop_with_bpm_and_key() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_suno_sound(GenerateSunoSoundArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSunoSoundRequest {
|
||||
prompt: "Lo-fi vinyl crackle groove".to_string(),
|
||||
sound_type: KinoviSunoSoundType::Loopable,
|
||||
bpm: Some(85),
|
||||
key: KinoviSunoSoundKey::AMinor,
|
||||
},
|
||||
}).await?;
|
||||
println!("suno sound loop — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod generate_suno_music;
|
||||
pub mod generate_suno_remix;
|
||||
pub mod generate_suno_sample;
|
||||
pub mod generate_suno_sound;
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod audio;
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
|
||||
+48
-6
@@ -274,10 +274,6 @@ pub struct WorkflowRunTaskResponse {
|
||||
// --- Implementation ---
|
||||
|
||||
pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
|
||||
let host = resolve_host(args.host_override.as_ref());
|
||||
let base_url = host.api_base_url();
|
||||
let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url);
|
||||
|
||||
let req = args.request;
|
||||
|
||||
info!("Requesting video from Seedance2Pro (v2): {:?}", req);
|
||||
@@ -286,7 +282,53 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
|
||||
|
||||
info!("Seedance2pro request (v2): {:?}", request_body);
|
||||
|
||||
let cookie = args.session.cookies.as_str();
|
||||
send_run_task_request(args.session, args.host_override, &request_body).await
|
||||
}
|
||||
|
||||
/// Run a `workflow.runTask` call whose `apiParams` shape differs from the
|
||||
/// standard video request — e.g. the Suno audio models, which carry their own
|
||||
/// parameter sets. The caller provides the tRPC `businessType` and a
|
||||
/// serializable `apiParams` payload; the HTTP plumbing and response handling
|
||||
/// are shared with [`workflow_run_task`].
|
||||
pub async fn workflow_run_task_custom<T: serde::Serialize + std::fmt::Debug>(
|
||||
args: WorkflowRunTaskCustomArgs<'_, T>,
|
||||
) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
|
||||
info!(
|
||||
"Requesting {} from Seedance2Pro (custom): {:?}",
|
||||
args.business_type, args.api_params,
|
||||
);
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"0": {
|
||||
"json": {
|
||||
"businessType": args.business_type,
|
||||
"apiParams": args.api_params,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
send_run_task_request(args.session, args.host_override, &request_body).await
|
||||
}
|
||||
|
||||
/// Bundle for [`workflow_run_task_custom`].
|
||||
pub struct WorkflowRunTaskCustomArgs<'a, T: serde::Serialize + std::fmt::Debug> {
|
||||
/// The tRPC `businessType` discriminator (e.g. "suno-music-generation").
|
||||
pub business_type: &'static str,
|
||||
pub api_params: T,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
async fn send_run_task_request<B: serde::Serialize>(
|
||||
session: &Seedance2ProSession,
|
||||
host_override: Option<KinoviHost>,
|
||||
request_body: &B,
|
||||
) -> Result<WorkflowRunTaskResponse, Seedance2ProError> {
|
||||
let host = resolve_host(host_override.as_ref());
|
||||
let base_url = host.api_base_url();
|
||||
let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url);
|
||||
|
||||
let cookie = session.cookies.as_str();
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
@@ -311,7 +353,7 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
|
||||
.header("Sec-Fetch-Site", "same-origin")
|
||||
.header("Priority", "u=4")
|
||||
.header("TE", "trailers")
|
||||
.json(&request_body)
|
||||
.json(request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
|
||||
|
||||
+3
@@ -68,6 +68,9 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
||||
RouterVideoModel::Veo3Fast => Some(CommonModelType::Veo3Fast),
|
||||
RouterVideoModel::Veo3p1 => Some(CommonModelType::Veo3p1),
|
||||
RouterVideoModel::Veo3p1Fast => Some(CommonModelType::Veo3p1Fast),
|
||||
RouterVideoModel::Veo3p1Lite => Some(CommonModelType::Veo3p1Lite),
|
||||
RouterVideoModel::ViduQ3 => Some(CommonModelType::ViduQ3),
|
||||
RouterVideoModel::ViduQ3Turbo => Some(CommonModelType::ViduQ3Turbo),
|
||||
RouterVideoModel::Seedance2p0BytePlus => Some(CommonModelType::Seedance2p0BytePlus),
|
||||
RouterVideoModel::Seedance2p0BytePlusFast => Some(CommonModelType::Seedance2p0BytePlusFast),
|
||||
RouterVideoModel::Seedance2p0Ultra => Some(CommonModelType::Seedance2p0Ultra),
|
||||
|
||||
@@ -82,6 +82,7 @@ fn do_build_cors_config(is_production: bool) -> Cors {
|
||||
actix_http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, // https://stackoverflow.com/a/46412839
|
||||
actix_http::header::HeaderName::from_static("x-requested-with"), // Tabulator Ajax sends
|
||||
actix_http::header::HeaderName::from_static("session"), // Custom header sent by Three.js Storyteller Studio
|
||||
actix_http::header::HeaderName::from_static("x-artcraft-version"), // ArtCraft client app release, surfaced in error alerts
|
||||
])
|
||||
.max_age(3600)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
http_host: None,
|
||||
http_origin: None,
|
||||
http_referer: None,
|
||||
http_user_agent: None,
|
||||
artcraft_version: None,
|
||||
trace_id: None,
|
||||
request_ip_address: None,
|
||||
avt_cookie_token: None,
|
||||
session_token: None,
|
||||
@@ -228,6 +234,12 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
http_host: None,
|
||||
http_origin: None,
|
||||
http_referer: None,
|
||||
http_user_agent: None,
|
||||
artcraft_version: None,
|
||||
trace_id: None,
|
||||
request_ip_address: None,
|
||||
avt_cookie_token: None,
|
||||
session_token: None,
|
||||
|
||||
@@ -37,6 +37,27 @@ pub struct NotificationDetails {
|
||||
/// HTTP status code associated with the event, if any.
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
|
||||
/// Hostname the client used to reach this server (from the `Host` or
|
||||
/// forwarded-host headers), if any. Useful when a server is reachable
|
||||
/// via multiple domains (eg. api.storyteller.ai, api.fakeyou.com).
|
||||
pub(crate) http_host: Option<String>,
|
||||
|
||||
/// `Origin` header of the request, if any. For browser XHR/fetch this is
|
||||
/// the scheme+host of the calling frontend.
|
||||
pub(crate) http_origin: Option<String>,
|
||||
|
||||
/// `Referer` header of the request, if any. For browser XHR/fetch this is
|
||||
/// the URL of the page that made the call (possibly truncated to the
|
||||
/// origin by the browser's referrer policy).
|
||||
pub(crate) http_referer: Option<String>,
|
||||
|
||||
/// `User-Agent` header of the request, if any.
|
||||
pub(crate) http_user_agent: Option<String>,
|
||||
|
||||
/// `X-ArtCraft-Version` header of the request, if any. Sent by ArtCraft
|
||||
/// clients to identify which app release made the call.
|
||||
pub(crate) artcraft_version: Option<String>,
|
||||
|
||||
/// User token associated with the event, if any.
|
||||
pub(crate) user_token: Option<String>,
|
||||
|
||||
@@ -188,6 +209,26 @@ impl NotificationDetails {
|
||||
parts.push(format!("HTTP Status Code: {}", status_code));
|
||||
}
|
||||
|
||||
if let Some(host) = &self.http_host {
|
||||
parts.push(format!("HTTP Host: {}", host));
|
||||
}
|
||||
|
||||
if let Some(origin) = &self.http_origin {
|
||||
parts.push(format!("HTTP Origin: {}", origin));
|
||||
}
|
||||
|
||||
if let Some(referer) = &self.http_referer {
|
||||
parts.push(format!("HTTP Referer: {}", referer));
|
||||
}
|
||||
|
||||
if let Some(user_agent) = &self.http_user_agent {
|
||||
parts.push(format!("User Agent: {}", user_agent));
|
||||
}
|
||||
|
||||
if let Some(version) = &self.artcraft_version {
|
||||
parts.push(format!("ArtCraft Version: {}", version));
|
||||
}
|
||||
|
||||
if let Some(ip) = &self.request_ip_address {
|
||||
parts.push(format!("Request IP: {}", ip));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ pub struct NotificationDetailsBuilder {
|
||||
pub(crate) http_method: Option<String>,
|
||||
pub(crate) http_path: Option<String>,
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
pub(crate) http_host: Option<String>,
|
||||
pub(crate) http_origin: Option<String>,
|
||||
pub(crate) http_referer: Option<String>,
|
||||
pub(crate) http_user_agent: Option<String>,
|
||||
pub(crate) artcraft_version: Option<String>,
|
||||
|
||||
pub(crate) user_token: Option<String>,
|
||||
pub(crate) media_file_token: Option<String>,
|
||||
@@ -53,6 +58,11 @@ impl NotificationDetailsBuilder {
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
http_host: None,
|
||||
http_origin: None,
|
||||
http_referer: None,
|
||||
http_user_agent: None,
|
||||
artcraft_version: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
@@ -84,6 +94,11 @@ impl NotificationDetailsBuilder {
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
http_host: None,
|
||||
http_origin: None,
|
||||
http_referer: None,
|
||||
http_user_agent: None,
|
||||
artcraft_version: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
@@ -138,6 +153,31 @@ impl NotificationDetailsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_http_host(mut self, http_host: Option<String>) -> Self {
|
||||
self.http_host = http_host;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_http_origin(mut self, http_origin: Option<String>) -> Self {
|
||||
self.http_origin = http_origin;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_http_referer(mut self, http_referer: Option<String>) -> Self {
|
||||
self.http_referer = http_referer;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_http_user_agent(mut self, http_user_agent: Option<String>) -> Self {
|
||||
self.http_user_agent = http_user_agent;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_artcraft_version(mut self, artcraft_version: Option<String>) -> Self {
|
||||
self.artcraft_version = artcraft_version;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_user_token(mut self, user_token: Option<String>) -> Self {
|
||||
self.user_token = user_token;
|
||||
self
|
||||
@@ -202,6 +242,11 @@ impl NotificationDetailsBuilder {
|
||||
http_method: self.http_method,
|
||||
http_path: self.http_path,
|
||||
http_status_code: self.http_status_code,
|
||||
http_host: self.http_host,
|
||||
http_origin: self.http_origin,
|
||||
http_referer: self.http_referer,
|
||||
http_user_agent: self.http_user_agent,
|
||||
artcraft_version: self.artcraft_version,
|
||||
user_token: self.user_token,
|
||||
media_file_token: self.media_file_token,
|
||||
inference_job_token: self.inference_job_token,
|
||||
|
||||
+29
-9
@@ -107,6 +107,7 @@ pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> An
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
count_fields.as_str(),
|
||||
true,
|
||||
);
|
||||
|
||||
let row_count_query = count_query_builder.build_query_scalar::<i64>();
|
||||
@@ -126,6 +127,7 @@ pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> An
|
||||
args.sort_ascending,
|
||||
args.view_as,
|
||||
result_fields.as_str(),
|
||||
false,
|
||||
);
|
||||
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
@@ -250,13 +252,20 @@ fn query_builder<'a>(
|
||||
sort_ascending: bool,
|
||||
view_as: ViewAs,
|
||||
select_fields: &'a str,
|
||||
count_only: bool,
|
||||
) -> QueryBuilder<'a, MySql> {
|
||||
|
||||
// NB: Query cannot be statically checked by sqlx
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
|
||||
format!(r#"
|
||||
SELECT
|
||||
{select_fields}
|
||||
// NB: The count query only needs the `users` join (for the username predicate).
|
||||
// The other joins are all 1:1 and contribute nothing to the count — MySQL does
|
||||
// not eliminate LEFT JOINs, so including them costs four index lookups per row.
|
||||
let from_clause = if count_only {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
"#
|
||||
} else {
|
||||
r#"
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN users AS u
|
||||
ON m.maybe_creator_user_token = u.token
|
||||
@@ -270,6 +279,14 @@ LEFT OUTER JOIN entity_stats
|
||||
LEFT OUTER JOIN prompts
|
||||
ON prompts.token = m.maybe_prompt_token
|
||||
"#
|
||||
};
|
||||
|
||||
// NB: Query cannot be statically checked by sqlx
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
|
||||
format!(r#"
|
||||
SELECT
|
||||
{select_fields}
|
||||
{from_clause}"#
|
||||
));
|
||||
|
||||
query_builder.push(" WHERE u.username = ");
|
||||
@@ -353,10 +370,13 @@ LEFT OUTER JOIN prompts
|
||||
}
|
||||
}
|
||||
|
||||
if sort_ascending {
|
||||
query_builder.push(" ORDER BY m.created_at ASC ");
|
||||
} else {
|
||||
query_builder.push(" ORDER BY m.created_at DESC ");
|
||||
// NB: Ordering is meaningless for the single-row COUNT aggregate.
|
||||
if !count_only {
|
||||
if sort_ascending {
|
||||
query_builder.push(" ORDER BY m.created_at ASC ");
|
||||
} else {
|
||||
query_builder.push(" ORDER BY m.created_at DESC ");
|
||||
}
|
||||
}
|
||||
|
||||
if enforce_limits {
|
||||
|
||||
-472
@@ -1,472 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
|
||||
use enums::by_table::model_weights::{
|
||||
weights_category::WeightsCategory,
|
||||
weights_types::WeightsType,
|
||||
};
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::traits::mysql_from_row::MySqlFromRow as _;
|
||||
use enums::common::visibility::Visibility;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct FeaturedWeightsPage {
|
||||
pub records: Vec<FeaturedWeight>,
|
||||
|
||||
pub sort_ascending: bool,
|
||||
|
||||
/// ID of the first record in the result set.
|
||||
pub first_id: Option<i64>,
|
||||
|
||||
/// ID of the last record in the result set.
|
||||
pub last_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FeaturedWeight {
|
||||
pub token: ModelWeightToken,
|
||||
|
||||
pub weights_type: WeightsType,
|
||||
pub weights_category: WeightsCategory,
|
||||
|
||||
pub title: String,
|
||||
|
||||
// TODO(bt,2023-12-24): These aren't really appropriate for a list endpoint.
|
||||
// Hopefully we don't break the frontend by omitting these.
|
||||
//pub maybe_description_markdown: String,
|
||||
//pub maybe_description_rendered_html: String,
|
||||
|
||||
pub maybe_ietf_language_tag: Option<String>,
|
||||
pub maybe_ietf_primary_language_subtag: Option<String>,
|
||||
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
pub maybe_creator_username: Option<String>,
|
||||
pub maybe_creator_display_name: Option<String>,
|
||||
pub maybe_creator_email_gravatar_hash: Option<String>,
|
||||
|
||||
pub creator_ip_address: String,
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub maybe_last_update_user_token: Option<UserToken>,
|
||||
|
||||
pub original_download_url: Option<String>,
|
||||
pub original_filename: Option<String>,
|
||||
|
||||
pub file_size_bytes: i32,
|
||||
pub file_checksum_sha2: String,
|
||||
|
||||
pub public_bucket_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_ratings_positive_count: Option<u32>,
|
||||
pub maybe_ratings_negative_count: Option<u32>,
|
||||
pub maybe_bookmark_count: Option<u32>,
|
||||
pub cached_usage_count: u64,
|
||||
|
||||
pub version: i32,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
pub user_deleted_at: Option<DateTime<Utc>>,
|
||||
pub mod_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct ListFeaturedWeightsArgs<'a> {
|
||||
pub limit: usize,
|
||||
pub maybe_offset: Option<usize>,
|
||||
|
||||
pub cursor_is_reversed: bool,
|
||||
pub sort_ascending: bool,
|
||||
|
||||
pub view_as: ViewAs,
|
||||
|
||||
pub maybe_scoped_weight_types: Option<&'a HashSet<WeightsType>>,
|
||||
pub maybe_scoped_weight_categories: Option<&'a HashSet<WeightsCategory>>,
|
||||
|
||||
pub mysql_pool: &'a MySqlPool,
|
||||
}
|
||||
|
||||
pub async fn list_featured_weights(args: ListFeaturedWeightsArgs<'_>) -> AnyhowResult<FeaturedWeightsPage> {
|
||||
// TODO(bt,2024-05-05): Why were we doing this, again?
|
||||
// let count_fields = select_total_count_field();
|
||||
// let mut count_query_builder = query_builder(
|
||||
// args.limit,
|
||||
// args.maybe_offset,
|
||||
// args.cursor_is_reversed,
|
||||
// args.sort_ascending,
|
||||
// count_fields.as_str(),
|
||||
// args.view_as,
|
||||
// args.maybe_scoped_weight_type,
|
||||
// args.maybe_scoped_weight_category,
|
||||
// );
|
||||
// let row_count_query = count_query_builder.build_query_scalar::<i64>();
|
||||
// let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
|
||||
|
||||
/// Now fetch the actual results with all the fields
|
||||
let result_fields = select_result_fields();
|
||||
let mut query = query_builder(
|
||||
args.limit,
|
||||
args.maybe_offset,
|
||||
args.cursor_is_reversed,
|
||||
args.sort_ascending,
|
||||
result_fields.as_str(),
|
||||
args.view_as,
|
||||
args.maybe_scoped_weight_types,
|
||||
args.maybe_scoped_weight_categories,
|
||||
);
|
||||
|
||||
let query = query.build_query_as::<RawWeightJoinUser>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let first_id = results.first()
|
||||
.map(|raw_result| raw_result.id);
|
||||
|
||||
let last_id = results.last()
|
||||
.map(|raw_result| raw_result.id);
|
||||
|
||||
let weights_records: Vec<FeaturedWeight> = map_to_weights(results).await;
|
||||
|
||||
Ok(FeaturedWeightsPage {
|
||||
records: weights_records,
|
||||
sort_ascending: args.sort_ascending,
|
||||
first_id,
|
||||
last_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_total_count_field() -> String {
|
||||
r#"
|
||||
COUNT(mw.id) AS total_count
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn select_result_fields() -> String {
|
||||
r#"
|
||||
mw.id,
|
||||
mw.token,
|
||||
mw.title,
|
||||
mw.weights_type,
|
||||
mw.weights_category,
|
||||
mw.maybe_ietf_language_tag,
|
||||
mw.maybe_ietf_primary_language_subtag,
|
||||
u.token as maybe_creator_user_token,
|
||||
u.username as maybe_creator_username,
|
||||
u.display_name as maybe_creator_display_name,
|
||||
u.email_gravatar_hash as maybe_creator_email_gravatar_hash,
|
||||
mw.creator_ip_address,
|
||||
mw.creator_set_visibility,
|
||||
mw.maybe_last_update_user_token,
|
||||
mw.original_download_url,
|
||||
mw.original_filename,
|
||||
mw.file_size_bytes,
|
||||
mw.file_checksum_sha2,
|
||||
|
||||
mw.public_bucket_hash,
|
||||
mw.maybe_public_bucket_prefix,
|
||||
mw.maybe_public_bucket_extension,
|
||||
|
||||
cover_image.public_bucket_directory_hash as maybe_cover_image_public_bucket_hash,
|
||||
cover_image.maybe_public_bucket_prefix as maybe_cover_image_public_bucket_prefix,
|
||||
cover_image.maybe_public_bucket_extension as maybe_cover_image_public_bucket_extension,
|
||||
|
||||
entity_stats.ratings_positive_count as maybe_ratings_positive_count,
|
||||
entity_stats.ratings_negative_count as maybe_ratings_negative_count,
|
||||
entity_stats.bookmark_count as maybe_bookmark_count,
|
||||
mw.cached_usage_count,
|
||||
|
||||
mw.version,
|
||||
mw.created_at,
|
||||
mw.updated_at,
|
||||
mw.user_deleted_at,
|
||||
mw.mod_deleted_at
|
||||
"#.to_string()
|
||||
}
|
||||
|
||||
fn query_builder<'a>(
|
||||
_limit: usize, // TODO(bt,2024-05-16): This seems wrong. Why is this unused?
|
||||
maybe_offset: Option<usize>,
|
||||
cursor_is_reversed: bool,
|
||||
sort_ascending: bool,
|
||||
select_fields: &'a str,
|
||||
view_as: ViewAs,
|
||||
maybe_scoped_weight_types: Option<&'a HashSet<WeightsType>>,
|
||||
maybe_scoped_weight_categories: Option<&'a HashSet<WeightsCategory>>,
|
||||
) -> QueryBuilder<'a, MySql> {
|
||||
|
||||
// NB: Query cannot be statically checked by sqlx
|
||||
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
|
||||
format!(r#"
|
||||
SELECT
|
||||
{select_fields}
|
||||
FROM model_weights as mw
|
||||
|
||||
JOIN featured_items
|
||||
ON featured_items.entity_type = "model_weight"
|
||||
AND featured_items.deleted_at IS NULL
|
||||
AND featured_items.entity_token = mw.token
|
||||
|
||||
LEFT OUTER JOIN users as u
|
||||
ON u.token = mw.creator_user_token
|
||||
LEFT OUTER JOIN media_files as cover_image
|
||||
ON cover_image.token = mw.maybe_cover_image_media_file_token
|
||||
LEFT OUTER JOIN entity_stats
|
||||
ON entity_stats.entity_type = "model_weights"
|
||||
AND entity_stats.entity_token = mw.token
|
||||
"#
|
||||
));
|
||||
|
||||
let mut first_predicate_added = false;
|
||||
|
||||
match view_as {
|
||||
ViewAs::Author | ViewAs::Moderator => {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
// NB(bt): Actually, mods don't want to see deleted models. We'll improve the moderator UI later.
|
||||
query_builder.push(" mw.user_deleted_at IS NULL AND mw.mod_deleted_at IS NULL ");
|
||||
}
|
||||
ViewAs::AnotherUser => {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
query_builder.push(" mw.user_deleted_at IS NULL AND mw.mod_deleted_at IS NULL ");
|
||||
query_builder.push(" AND mw.creator_set_visibility = ");
|
||||
query_builder.push_bind(Visibility::Public.to_str());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(weight_types) = maybe_scoped_weight_types {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !weight_types.is_empty() {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
query_builder.push(" mw.weights_type IN ( ");
|
||||
let mut separated = query_builder.separated(", ");
|
||||
for weight_type in weight_types.iter() {
|
||||
separated.push_bind(weight_type.to_str());
|
||||
}
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(weight_categories) = maybe_scoped_weight_categories {
|
||||
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
|
||||
// We'll skip the predicate if the list isn't empty.
|
||||
if !weight_categories.is_empty() {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
query_builder.push(" mw.weights_category IN ( ");
|
||||
let mut separated = query_builder.separated(", ");
|
||||
for weights_category in weight_categories.iter() {
|
||||
separated.push_bind(weights_category.to_str());
|
||||
}
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(offset) = maybe_offset {
|
||||
if !first_predicate_added {
|
||||
query_builder.push(" WHERE ");
|
||||
first_predicate_added = true;
|
||||
} else {
|
||||
query_builder.push(" AND ");
|
||||
}
|
||||
|
||||
let mut sort_ascending = sort_ascending;
|
||||
|
||||
if sort_ascending {
|
||||
if cursor_is_reversed {
|
||||
// NB: We're searching backwards.
|
||||
query_builder.push(" m.id < ");
|
||||
sort_ascending = !sort_ascending;
|
||||
} else {
|
||||
query_builder.push(" m.id > ");
|
||||
}
|
||||
} else {
|
||||
if cursor_is_reversed {
|
||||
// NB: We're searching backwards.
|
||||
query_builder.push(" m.id > ");
|
||||
sort_ascending = !sort_ascending;
|
||||
} else {
|
||||
query_builder.push(" m.id < ");
|
||||
}
|
||||
}
|
||||
query_builder.push_bind(offset as i64);
|
||||
}
|
||||
|
||||
query_builder
|
||||
}
|
||||
|
||||
async fn map_to_weights(dataset:Vec<RawWeightJoinUser>) -> Vec<FeaturedWeight> {
|
||||
let weights: Vec<FeaturedWeight> = dataset
|
||||
.into_iter()
|
||||
.map(|weight: RawWeightJoinUser| {
|
||||
FeaturedWeight {
|
||||
token: weight.token,
|
||||
title: weight.title,
|
||||
weights_type: weight.weights_type,
|
||||
weights_category: weight.weights_category,
|
||||
|
||||
maybe_ietf_language_tag: weight.maybe_ietf_language_tag,
|
||||
maybe_ietf_primary_language_subtag: weight.maybe_ietf_primary_language_subtag,
|
||||
|
||||
maybe_creator_user_token: weight.maybe_creator_user_token,
|
||||
maybe_creator_username: weight.maybe_creator_username,
|
||||
maybe_creator_display_name: weight.maybe_creator_display_name,
|
||||
maybe_creator_email_gravatar_hash: weight.maybe_creator_email_gravatar_hash,
|
||||
|
||||
creator_ip_address: weight.creator_ip_address,
|
||||
creator_set_visibility: weight.creator_set_visibility,
|
||||
|
||||
maybe_last_update_user_token: weight.maybe_last_update_user_token,
|
||||
original_download_url: weight.original_download_url,
|
||||
original_filename: weight.original_filename,
|
||||
file_size_bytes: weight.file_size_bytes,
|
||||
file_checksum_sha2: weight.file_checksum_sha2,
|
||||
public_bucket_hash: weight.public_bucket_hash,
|
||||
maybe_public_bucket_prefix: weight.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: weight.maybe_public_bucket_extension,
|
||||
|
||||
maybe_cover_image_public_bucket_hash: weight.maybe_cover_image_public_bucket_hash,
|
||||
maybe_cover_image_public_bucket_prefix: weight.maybe_cover_image_public_bucket_prefix,
|
||||
maybe_cover_image_public_bucket_extension: weight.maybe_cover_image_public_bucket_extension,
|
||||
|
||||
maybe_ratings_positive_count: weight.maybe_ratings_positive_count,
|
||||
maybe_ratings_negative_count: weight.maybe_ratings_negative_count,
|
||||
maybe_bookmark_count: weight.maybe_bookmark_count,
|
||||
cached_usage_count: weight.cached_usage_count,
|
||||
|
||||
version: weight.version,
|
||||
created_at: weight.created_at,
|
||||
updated_at: weight.updated_at,
|
||||
user_deleted_at: weight.user_deleted_at,
|
||||
mod_deleted_at: weight.mod_deleted_at,
|
||||
}
|
||||
}).collect();
|
||||
weights
|
||||
}
|
||||
|
||||
struct RawWeightJoinUser {
|
||||
id: i64,
|
||||
token: ModelWeightToken,
|
||||
|
||||
weights_type: WeightsType,
|
||||
weights_category: WeightsCategory,
|
||||
|
||||
title: String,
|
||||
|
||||
maybe_ietf_language_tag: Option<String>,
|
||||
maybe_ietf_primary_language_subtag: Option<String>,
|
||||
|
||||
// TODO(bt,2023-12-24): These aren't really appropriate for a list endpoint.
|
||||
// Hopefully we don't break the frontend by omitting these.
|
||||
//pub description_markdown: String,
|
||||
//pub description_rendered_html: String,
|
||||
|
||||
maybe_creator_user_token: Option<UserToken>,
|
||||
maybe_creator_username: Option<String>,
|
||||
maybe_creator_display_name: Option<String>,
|
||||
maybe_creator_email_gravatar_hash: Option<String>,
|
||||
|
||||
creator_ip_address: String,
|
||||
creator_set_visibility: Visibility,
|
||||
|
||||
maybe_last_update_user_token: Option<UserToken>,
|
||||
|
||||
original_download_url: Option<String>,
|
||||
original_filename: Option<String>,
|
||||
|
||||
file_size_bytes: i32,
|
||||
file_checksum_sha2: String,
|
||||
|
||||
public_bucket_hash: String,
|
||||
maybe_public_bucket_prefix: Option<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
cached_usage_count: u64,
|
||||
|
||||
maybe_cover_image_public_bucket_hash: Option<String>,
|
||||
maybe_cover_image_public_bucket_prefix: Option<String>,
|
||||
maybe_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
maybe_ratings_positive_count: Option<u32>,
|
||||
maybe_ratings_negative_count: Option<u32>,
|
||||
maybe_bookmark_count: Option<u32>,
|
||||
|
||||
version: i32,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
|
||||
user_deleted_at: Option<DateTime<Utc>>,
|
||||
mod_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl FromRow<'_, MySqlRow> for RawWeightJoinUser {
|
||||
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
id: row.try_get("id")?,
|
||||
token: row.try_get("token")?,
|
||||
weights_type: row.try_get("weights_type")?,
|
||||
weights_category: row.try_get("weights_category")?,
|
||||
title: row.try_get("title")?,
|
||||
maybe_ietf_language_tag: row.try_get("maybe_ietf_language_tag")?,
|
||||
maybe_ietf_primary_language_subtag: row.try_get("maybe_ietf_primary_language_subtag")?,
|
||||
// TODO(bt,2023-12-24): These aren't really appropriate for a list endpoint.
|
||||
// Hopefully we don't break the frontend by omitting these.
|
||||
//maybe_description_markdown: row.try_get("maybe_description_markdown")?,
|
||||
//maybe_description_rendered_html: row.try_get("maybe_description_rendered_html")?,
|
||||
maybe_creator_user_token: row.try_get("maybe_creator_user_token")?,
|
||||
maybe_creator_username: row.try_get("maybe_creator_username")?,
|
||||
maybe_creator_display_name: row.try_get("maybe_creator_display_name")?,
|
||||
maybe_creator_email_gravatar_hash: row.try_get("maybe_creator_email_gravatar_hash")?,
|
||||
creator_ip_address: row.try_get("creator_ip_address")?,
|
||||
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
|
||||
maybe_last_update_user_token: row.try_get("maybe_last_update_user_token")?,
|
||||
original_download_url: row.try_get("original_download_url")?,
|
||||
original_filename: row.try_get("original_filename")?,
|
||||
file_size_bytes: row.try_get("file_size_bytes")?,
|
||||
file_checksum_sha2: row.try_get("file_checksum_sha2")?,
|
||||
public_bucket_hash: row.try_get("public_bucket_hash")?,
|
||||
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
|
||||
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
|
||||
cached_usage_count: row.try_get("cached_usage_count")?,
|
||||
maybe_cover_image_public_bucket_hash: row.try_get("maybe_cover_image_public_bucket_hash")?,
|
||||
maybe_cover_image_public_bucket_prefix: row.try_get("maybe_cover_image_public_bucket_prefix")?,
|
||||
maybe_cover_image_public_bucket_extension: row.try_get("maybe_cover_image_public_bucket_extension")?,
|
||||
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
|
||||
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
|
||||
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
|
||||
version: row.try_get("version")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
user_deleted_at: row.try_get("user_deleted_at")?,
|
||||
mod_deleted_at: row.try_get("mod_deleted_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod list_all_model_weight_tokens_for_backfill;
|
||||
pub mod list_all_model_weight_tokens_for_cached_usage_backfill;
|
||||
pub mod list_featured_weights;
|
||||
pub mod list_model_weight_tokens_updated_since;
|
||||
pub mod list_model_weights_for_elastic_search_backfill_using_cursor;
|
||||
pub mod list_model_weights_for_text_to_speech;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::helpers::numeric_converters::try_i64_to_u64_or_min;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct QueueStatsRow {
|
||||
pub queue_type: String,
|
||||
pub pending_job_count: u64,
|
||||
pub present_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
struct QueueStatsRowInternal {
|
||||
queue_type: Option<String>, // FIXME: Shouldn't be nullable
|
||||
pending_job_count: i64,
|
||||
present_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
// NB: Returning Option<T> is a hack to fit with outer Redis caching.
|
||||
// I'm only doing this because I'm in a hurry. Don't do this.
|
||||
pub async fn get_unified_queue_stats(
|
||||
pool: &MySqlPool,
|
||||
) -> AnyhowResult<Vec<QueueStatsRow>> {
|
||||
// NB (1): We query as a union since "started" jobs can get picked off mid-run and go stale
|
||||
// forever. We currently have no automated means of collecting those jobs.
|
||||
// NB (2): We query the server timestamp so we can cache the results.
|
||||
// The frontend can then monotonically adjust the count based on timestamp by ignoring
|
||||
// past times.
|
||||
let rows : Vec<QueueStatsRowInternal> = sqlx::query_as!(
|
||||
QueueStatsRowInternal,
|
||||
r#"
|
||||
SELECT
|
||||
job_type as queue_type,
|
||||
count(*) as pending_job_count,
|
||||
NOW() as present_time
|
||||
FROM (
|
||||
SELECT
|
||||
token,
|
||||
job_type
|
||||
FROM generic_inference_jobs
|
||||
WHERE status IN ("pending", "attempt_failed")
|
||||
AND job_type IS NOT NULL
|
||||
UNION
|
||||
SELECT
|
||||
token,
|
||||
job_type
|
||||
FROM generic_inference_jobs
|
||||
WHERE status IN ("started")
|
||||
AND created_at > (CURDATE() - INTERVAL 15 MINUTE)
|
||||
AND job_type IS NOT NULL
|
||||
) as generic_inner
|
||||
GROUP BY queue_type
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|row|
|
||||
QueueStatsRow {
|
||||
queue_type: row.queue_type.unwrap_or_else(|| "unknown".to_string()),
|
||||
pending_job_count: try_i64_to_u64_or_min(row.pending_job_count),
|
||||
present_time: row.present_time,
|
||||
}).collect::<Vec<_>>())
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod get_on_prem_worker_stats;
|
||||
pub mod get_unified_queue_stats;
|
||||
pub mod get_voice_count_stats;
|
||||
pub mod get_voice_count_stats;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod stats;
|
||||
pub mod tts_category_assignments;
|
||||
pub mod tts_download_jobs;
|
||||
pub mod tts_inference_jobs;
|
||||
pub mod tts_model_upload_jobs;
|
||||
pub mod tts_models;
|
||||
pub mod tts_results;
|
||||
pub mod tts_results;
|
||||
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use log::warn;
|
||||
use sqlx::MySql;
|
||||
use sqlx::pool::PoolConnection;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct TtsLeaderboardRecordForList {
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub gravatar_hash: String,
|
||||
pub creator_user_token: String,
|
||||
pub uploaded_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TtsLeaderboardRecordForListRaw {
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub gravatar_hash: String,
|
||||
pub creator_user_token: String,
|
||||
pub uploaded_count: i64,
|
||||
}
|
||||
|
||||
// NB: This may need to become an offline query if it is expensive.
|
||||
pub async fn calculate_tts_model_leaderboard(
|
||||
mysql_pool: &mut PoolConnection<MySql>
|
||||
) -> AnyhowResult<Vec<TtsLeaderboardRecordForList>> {
|
||||
|
||||
// NB(1): We're not requiring "is_public_listing_approved IS TRUE" for TTS models
|
||||
// This is the opposite for W2L templates (for now at least)
|
||||
// NB(2): We're excluding a few users (eg. Vegito's alternate account)
|
||||
// U:DRB1SKEMKHT1J - https://fakeyou.com/profile/bluefusion/uploaded-tts-models
|
||||
let maybe_results = sqlx::query_as!(
|
||||
TtsLeaderboardRecordForListRaw,
|
||||
r#"
|
||||
SELECT
|
||||
users.username,
|
||||
users.display_name,
|
||||
users.email_gravatar_hash as gravatar_hash,
|
||||
creator_user_token,
|
||||
count(*) as uploaded_count
|
||||
FROM tts_models
|
||||
JOIN users
|
||||
ON
|
||||
users.token = creator_user_token
|
||||
WHERE
|
||||
users.is_banned IS FALSE
|
||||
AND users.token NOT IN ("U:DRB1SKEMKHT1J")
|
||||
AND tts_models.user_deleted_at IS NULL
|
||||
AND tts_models.mod_deleted_at IS NULL
|
||||
GROUP BY creator_user_token
|
||||
ORDER BY uploaded_count desc
|
||||
LIMIT 25;
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&mut **mysql_pool)
|
||||
.await;
|
||||
|
||||
let results : Vec<TtsLeaderboardRecordForListRaw> = match maybe_results {
|
||||
Ok(results) => results,
|
||||
Err(err) => {
|
||||
warn!("Error: {:?}", err);
|
||||
|
||||
match err {
|
||||
sqlx::Error::RowNotFound => {
|
||||
return Ok(Vec::new());
|
||||
},
|
||||
_ => {
|
||||
warn!("tts leaderboard query error: {:?}", err);
|
||||
return Err(anyhow!("tts leaderboard query error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(results.into_iter()
|
||||
.map(|result| {
|
||||
TtsLeaderboardRecordForList {
|
||||
username: result.username.to_string(),
|
||||
display_name: result.display_name.to_string(),
|
||||
gravatar_hash: result.gravatar_hash.to_string(),
|
||||
creator_user_token: result.creator_user_token.to_string(),
|
||||
uploaded_count: result.uploaded_count,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<TtsLeaderboardRecordForList>>())
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod calculate_tts_model_leaderboard;
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{MySql, MySqlPool};
|
||||
use sqlx::pool::PoolConnection;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_optional_bool};
|
||||
|
||||
/// List of assigned categories for a TTS model
|
||||
/// This is *NOT* paginated, as that defeats the purpose of having a category system.
|
||||
#[derive(Serialize)]
|
||||
pub struct AssignedCategoryList {
|
||||
pub categories: Vec<AssignedCategory>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AssignedCategory {
|
||||
pub category_token: String,
|
||||
|
||||
pub model_type: String, // TODO: ENUM
|
||||
|
||||
pub maybe_super_category_token: Option<String>,
|
||||
|
||||
pub can_directly_have_models: bool,
|
||||
pub can_have_subcategories: bool,
|
||||
pub can_only_mods_apply: bool,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_dropdown_name: Option<String>,
|
||||
|
||||
pub category_creator_user_token: Option<String>,
|
||||
pub category_creator_username: Option<String>,
|
||||
pub category_creator_display_name: Option<String>,
|
||||
pub category_creator_gravatar_hash: Option<String>,
|
||||
|
||||
// Moderator fields
|
||||
pub is_mod_approved: Option<bool>,
|
||||
pub maybe_mod_comments: Option<String>,
|
||||
//pub maybe_mod_user_token: Option<String>,
|
||||
//pub maybe_mod_username: Option<String>,
|
||||
//pub maybe_mod_display_name: Option<String>,
|
||||
|
||||
pub category_created_at: DateTime<Utc>,
|
||||
pub category_updated_at: DateTime<Utc>,
|
||||
pub category_deleted_at: Option<DateTime<Utc>>,
|
||||
|
||||
pub category_assignment_created_at: DateTime<Utc>,
|
||||
pub category_assignment_updated_at: DateTime<Utc>,
|
||||
pub category_assignment_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Query Builder for listing categories
|
||||
/// These are very difficult queries, so the builder helps for
|
||||
/// testability, construction, and correctness.
|
||||
pub struct ListAssignedTtsCategoriesQueryBuilder {
|
||||
// REQUIRED.
|
||||
scope_tts_model_token: String,
|
||||
|
||||
// Sometimes "can_directly_have_models" categories change state.
|
||||
// Mods will want to see invalid uses.
|
||||
show_invalid_model_not_allowed_categories: bool,
|
||||
|
||||
// The public lists won't include unapproved, but mod views
|
||||
// will want to see them.
|
||||
show_unapproved: bool,
|
||||
|
||||
// The public lists won't include deleted models, but mod views
|
||||
// will want to see them.
|
||||
show_deleted: bool,
|
||||
}
|
||||
|
||||
impl ListAssignedTtsCategoriesQueryBuilder {
|
||||
pub fn for_model_token(model_token: &str) -> Self {
|
||||
Self {
|
||||
scope_tts_model_token: model_token.to_string(),
|
||||
show_invalid_model_not_allowed_categories: false,
|
||||
show_unapproved: false,
|
||||
show_deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_invalid_model_not_allowed_categories(mut self, show_invalid_model_not_allowed_categories: bool) -> Self {
|
||||
self.show_invalid_model_not_allowed_categories = show_invalid_model_not_allowed_categories;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_unapproved(mut self, show_unapproved: bool) -> Self {
|
||||
self.show_unapproved = show_unapproved;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_deleted(mut self, show_deleted: bool) -> Self {
|
||||
self.show_deleted = show_deleted;
|
||||
self
|
||||
}
|
||||
|
||||
/// Perform the query based on the set predicates.
|
||||
#[deprecated = "Use the PoolConnection<MySql> method instead of the MySqlPool one."]
|
||||
pub async fn perform_query(
|
||||
&self,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<AssignedCategoryList> {
|
||||
let mut mysql_connection = mysql_pool.acquire().await?;
|
||||
self.perform_query_by_connection(&mut mysql_connection).await
|
||||
}
|
||||
|
||||
/// Perform the query based on the set predicates.
|
||||
pub async fn perform_query_by_connection(
|
||||
&self,
|
||||
mysql_connection: &mut PoolConnection<MySql>
|
||||
) -> AnyhowResult<AssignedCategoryList> {
|
||||
|
||||
let internal_results = self.perform_internal_query(mysql_connection).await?;
|
||||
|
||||
let categories = internal_results
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
AssignedCategory {
|
||||
category_token: c.category_token,
|
||||
model_type: c.model_type,
|
||||
maybe_super_category_token: c.maybe_super_category_token,
|
||||
can_directly_have_models: i8_to_bool(c.can_directly_have_models),
|
||||
can_have_subcategories: i8_to_bool(c.can_have_subcategories),
|
||||
can_only_mods_apply: i8_to_bool(c.can_only_mods_apply),
|
||||
name: c.name,
|
||||
maybe_dropdown_name: c.maybe_dropdown_name,
|
||||
category_creator_user_token: c.category_creator_user_token,
|
||||
category_creator_username: c.category_creator_username,
|
||||
category_creator_display_name: c.category_creator_display_name,
|
||||
category_creator_gravatar_hash: c.category_creator_gravatar_hash,
|
||||
is_mod_approved: nullable_i8_to_optional_bool(c.is_mod_approved),
|
||||
maybe_mod_comments: c.maybe_mod_comments,
|
||||
category_created_at: c.category_created_at,
|
||||
category_updated_at: c.category_updated_at,
|
||||
category_deleted_at: c.category_deleted_at,
|
||||
category_assignment_created_at: c.category_assignment_created_at,
|
||||
category_assignment_updated_at: c.category_assignment_updated_at,
|
||||
category_assignment_deleted_at: c.category_assignment_deleted_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<AssignedCategory>>();
|
||||
|
||||
Ok(AssignedCategoryList {
|
||||
categories,
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the query based on the set predicates.
|
||||
async fn perform_internal_query(
|
||||
&self,
|
||||
mysql_connection: &mut PoolConnection<MySql>
|
||||
) -> AnyhowResult<Vec<RawInternalCategoryRecord>> {
|
||||
|
||||
let query = self.build_query_string();
|
||||
let mut query = sqlx::query_as::<_, RawInternalCategoryRecord>(&query);
|
||||
|
||||
// NB: The following bindings must match the order of the query builder !!
|
||||
|
||||
// NB: Model token is always used for scoping.
|
||||
query = query.bind(&self.scope_tts_model_token);
|
||||
|
||||
let results = query.fetch_all(&mut **mysql_connection)
|
||||
.await?;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn build_query_string(&self) -> String {
|
||||
// TODO: I haven't figured out how to get field name disambiguation and type coercion working here.
|
||||
// (1) tts_results.creator_set_visibility `creator_set_visibility: crate::database::enums::record_visibility::RecordVisibility`,
|
||||
// Query error: no column found for name: creator_set_visibility
|
||||
// (2) creator_set_visibility `creator_set_visibility: crate::database::enums::record_visibility::RecordVisibility`,
|
||||
// Column 'creator_set_visibility' in field list is ambiguous
|
||||
|
||||
// TODO/NB: Unfortunately SQLx can't statically typecheck this query
|
||||
let mut query = r#"
|
||||
SELECT
|
||||
model_categories.token as category_token,
|
||||
|
||||
model_categories.model_type,
|
||||
|
||||
model_categories.maybe_super_category_token,
|
||||
|
||||
model_categories.can_directly_have_models,
|
||||
model_categories.can_have_subcategories,
|
||||
model_categories.can_only_mods_apply,
|
||||
|
||||
model_categories.name,
|
||||
model_categories.maybe_dropdown_name,
|
||||
|
||||
users.token as category_creator_user_token,
|
||||
users.username as category_creator_username,
|
||||
users.display_name as category_creator_display_name,
|
||||
users.email_gravatar_hash as category_creator_gravatar_hash,
|
||||
|
||||
model_categories.is_mod_approved,
|
||||
model_categories.maybe_mod_comments,
|
||||
|
||||
model_categories.created_at as category_created_at,
|
||||
model_categories.updated_at as category_updated_at,
|
||||
model_categories.deleted_at as category_deleted_at,
|
||||
|
||||
tts_category_assignments.created_at as category_assignment_created_at,
|
||||
tts_category_assignments.updated_at as category_assignment_updated_at,
|
||||
tts_category_assignments.deleted_at as category_assignment_deleted_at
|
||||
|
||||
FROM model_categories
|
||||
JOIN tts_category_assignments
|
||||
ON tts_category_assignments.category_token = model_categories.token
|
||||
LEFT OUTER JOIN users
|
||||
ON model_categories.creator_user_token = users.token
|
||||
"#.to_string();
|
||||
|
||||
query.push_str(&self.build_predicates());
|
||||
query
|
||||
}
|
||||
|
||||
pub fn build_predicates(&self) -> String {
|
||||
let mut query = "".to_string();
|
||||
|
||||
// Scoping to tts model is required.
|
||||
query.push_str(" WHERE tts_category_assignments.model_token = ?");
|
||||
query.push_str(" AND model_categories.model_type = 'tts'");
|
||||
|
||||
// We also don't care to ever show soft-deleted assignments
|
||||
query.push_str(" AND tts_category_assignments.deleted_at IS NULL");
|
||||
|
||||
if !self.show_invalid_model_not_allowed_categories {
|
||||
query.push_str(" AND model_categories.can_directly_have_models IS TRUE");
|
||||
}
|
||||
|
||||
if !self.show_unapproved {
|
||||
query.push_str(" AND model_categories.is_mod_approved IS TRUE");
|
||||
}
|
||||
|
||||
if !self.show_deleted {
|
||||
query.push_str(" AND model_categories.deleted_at IS NULL");
|
||||
}
|
||||
|
||||
query
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct RawInternalCategoryRecord {
|
||||
pub category_token: String,
|
||||
|
||||
pub model_type: String, // TODO: ENUM
|
||||
|
||||
pub maybe_super_category_token: Option<String>,
|
||||
|
||||
pub can_directly_have_models: i8,
|
||||
pub can_have_subcategories: i8,
|
||||
pub can_only_mods_apply: i8,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_dropdown_name: Option<String>,
|
||||
|
||||
pub category_creator_user_token: Option<String>,
|
||||
pub category_creator_username: Option<String>,
|
||||
pub category_creator_display_name: Option<String>,
|
||||
pub category_creator_gravatar_hash: Option<String>,
|
||||
|
||||
// Moderator fields
|
||||
pub is_mod_approved: Option<i8>,
|
||||
pub maybe_mod_comments: Option<String>,
|
||||
|
||||
pub category_created_at: DateTime<Utc>,
|
||||
pub category_updated_at: DateTime<Utc>,
|
||||
pub category_deleted_at: Option<DateTime<Utc>>,
|
||||
|
||||
pub category_assignment_created_at: DateTime<Utc>,
|
||||
pub category_assignment_updated_at: DateTime<Utc>,
|
||||
pub category_assignment_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod fetch_and_build_tts_model_category_map;
|
||||
pub mod list_assigned_tts_categories_query_builder;
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TtsInferenceJobStatusRecord {
|
||||
pub job_token: String,
|
||||
|
||||
pub status: String,
|
||||
pub attempt_count: i32,
|
||||
|
||||
pub maybe_result_token: Option<String>,
|
||||
pub maybe_public_bucket_wav_audio_path: Option<String>,
|
||||
|
||||
pub model_token: String,
|
||||
pub tts_model_type: String,
|
||||
pub title: String, // Name of the TTS model
|
||||
|
||||
pub raw_inference_text: String, // User text
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn get_tts_inference_job_status(
|
||||
tts_inference_job_token: &str,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<Option<TtsInferenceJobStatusRecord>> {
|
||||
|
||||
// NB: Lookup failure is Err(RowNotFound).
|
||||
// NB: Since this is publicly exposed, we don't query sensitive data.
|
||||
let maybe_status = sqlx::query_as!(
|
||||
TtsInferenceJobStatusRecord,
|
||||
r#"
|
||||
SELECT
|
||||
jobs.token as job_token,
|
||||
|
||||
jobs.status,
|
||||
jobs.attempt_count,
|
||||
jobs.on_success_result_token as maybe_result_token,
|
||||
results.public_bucket_wav_audio_path as maybe_public_bucket_wav_audio_path,
|
||||
|
||||
jobs.model_token,
|
||||
tts.tts_model_type,
|
||||
tts.title,
|
||||
|
||||
jobs.raw_inference_text,
|
||||
|
||||
jobs.created_at,
|
||||
jobs.updated_at
|
||||
|
||||
FROM tts_inference_jobs as jobs
|
||||
JOIN tts_models as tts
|
||||
ON tts.token = jobs.model_token
|
||||
LEFT OUTER JOIN tts_results as results
|
||||
ON jobs.on_success_result_token = results.token
|
||||
|
||||
WHERE jobs.token = ?
|
||||
"#,
|
||||
tts_inference_job_token
|
||||
)
|
||||
.fetch_one(mysql_pool)
|
||||
.await; // TODO: This will return error if it doesn't exist
|
||||
|
||||
match maybe_status {
|
||||
Ok(record) => Ok(Some(record)),
|
||||
Err(err) => match err {
|
||||
sqlx::Error::RowNotFound => Ok(None),
|
||||
_ => Err(anyhow!("tts job query error: {:?}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user