Compare commits

...
Author SHA1 Message Date
bflatastic 58accbcf56 repositioned outliner above model selector and copy paste fixed? 2025-07-31 20:44:38 +07:00
BFlatandBrandon Thomas 708930dfb4 number of generation dropdown for text to image prompt box (#748)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2025-07-29 21:15:10 -04:00
Brandon Thomas 4f0916edb7 enqueue multiple images (#749) 2025-07-29 20:14:10 -04:00
bflatastic c36e88d8ce expandable lightbox to fullscreen 2025-07-29 20:11:04 +07:00
Brandon Thomas 233f60acf6 stop double alerting on events 2025-07-28 22:50:02 -04:00
Brandon Thomas a3e16e491a unlikely we'll need old frontend configuration "editor2d_studio_OAI" 2025-07-28 20:04:27 -04:00
Brandon Thomas 3d521fec79 roll forward with new build (07.28) 2025-07-28 10:16:14 -04:00
BFlat 6c8979123f 3d: fixed saving and new scene (#747) 2025-07-28 14:12:56 +07:00
BFlat 8d4edcfafd lightbox context images, generation details, canvas snapshot bug fix (#746)
- canvas snapshot aspect ratio is fixed (it was snapshotting only as
squared images)
- added reference/context images in the lightbox under prompt, hovering
shows a larger preview
- added generation details: model used with logo and provider
2025-07-28 13:28:17 +07:00
Brandon Thomas b53e138dab include prompt details in api docs 2025-07-26 19:10:48 -04:00
Brandon Thomas dc1b038ffa only serve image references for prompts (future proofing) 2025-07-26 18:59:08 -04:00
Brandon Thomas 418a9f92c0 insert prompt images, serve additional metadata (#745) 2025-07-26 18:53:09 -04:00
Brandon Thomas d671f7f951 record prompts on all generate/* andpoints (#744) 2025-07-25 18:29:44 -04:00
bflatastic e97e5c239e lightbox and download button fixes 2025-07-24 17:23:10 +07:00
bflatastic a7270f4685 lightbox prompt fetching 2025-07-24 16:21:21 +07:00
Brandon Thomas a6fb661fbe codegen db 2025-07-24 02:15:22 -04:00
Brandon Thomas e5f5a06b6b prompts code (#743) 2025-07-24 02:01:34 -04:00
Brandon Thomas 48a6b1a046 more migrations (#742) 2025-07-23 17:35:10 -04:00
Brandon Thomas 7791b9b6cc Bt prompts schema (#741) 2025-07-23 16:22:13 -04:00
Brandon Thomas ff30213af8 more GA instrumentation (#740) 2025-07-22 21:12:52 -04:00
Brandon Thomas 766f726dc1 Google Analytics package and events (#739) 2025-07-22 19:12:14 -04:00
Brandon Thomas 76d7d4c100 add microsoft clarity (#738) 2025-07-22 10:58:56 -04:00
BFlat 2902db074e Upload asset ux update (#737)
- image upload modal added
- everything under + button popover
<img width="393" height="263" alt="image"
src="https://github.com/user-attachments/assets/9f6bda8d-10c0-460f-8eab-227ad8155e68"
/>
2025-07-22 20:25:07 +07:00
Brandon Thomas b8ee03f1b7 load glb assets without cors (#736) 2025-07-22 08:43:38 -04:00
Brandon Thomas 2cf300588e inference job query reaper (#735) 2025-07-21 19:42:27 -04:00
BFlat d2720d0a62 services icons for the models list (#733)
added model service icons:
- google
- black forest labs
- bytedance
- kling
- midjourney
- openai
2025-07-21 14:51:15 +07:00
BFlat 69ed3ec48b bug fix: added z index to scene state (#734)
layers z-index now are persisted in scene state
2025-07-21 14:49:23 +07:00
bflatastic 81f5797e2f removed download link from completed card 2025-07-21 13:28:45 +07:00
Brandon Thomas a80c69ab1e update download links (#732) 2025-07-17 22:05:56 -04:00
Brandon Thomasandbflatastic da859fc2f8 marketing updates for the website (#722)
This is currently a draft / work in progress.

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
2025-07-17 10:49:14 -04:00
Brandon Thomas 5f832d5d17 fix thumbnails in gallery and jobs, fix veo2 args, add hunyuan args, etc. (#731) 2025-07-17 09:51:59 -04:00
Brandon Thomas 612e58483c add model type to tasks db (#730) 2025-07-16 21:49:46 -04:00
Brandon Thomas 11b07b5833 debugging, resizing improvements; cleanup + refactor (#729) 2025-07-16 19:19:30 -04:00
Kasisnu a3f3bd8b3f bump nx version 2025-07-16 20:48:44 +05:30
bflatastic a93e921b51 3d model upload fix, upload button, modal block propagation of keyboard events 2025-07-16 13:57:09 +07:00
Brandon Thomas 58d5a172c4 consolidate cloudflare handling code (#728) 2025-07-16 01:09:50 -04:00
799 changed files with 6352 additions and 47536 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
npm --version
# Remove mismatched @nx packages and reinstall with correct versions
npm install --save-dev @nx/remix@20.7.2 @nx/js@20.7.2 @nx/react@20.7.2 @nx/vite@20.7.2 @nx/eslint@20.7.2
npm install --save-dev @nx/remix@21.1.1 @nx/js@21.1.1 @nx/react@21.1.1 @nx/vite@21.1.1 @nx/eslint@21.1.1
echo "=== Nx versions after adjustment ==="
npm list --depth=0 | grep nx
@@ -51,7 +51,7 @@ jobs:
npm --version
# Remove mismatched @nx packages and reinstall with correct versions
npm install --save-dev @nx/remix@20.7.2 @nx/js@20.7.2 @nx/react@20.7.2 @nx/vite@20.7.2 @nx/eslint@20.7.2
npm install --save-dev @nx/remix@21.1.1 @nx/js@21.1.1 @nx/react@21.1.1 @nx/vite@21.1.1 @nx/eslint@21.1.1
echo "=== Nx versions after adjustment ==="
npm list --depth=0 | findstr nx
@@ -74,7 +74,7 @@ jobs:
# Update Tauri config to skip the build (since we already built)
cd ${{ github.workspace }}/crates/desktop/artcraft
Copy-Item tauri.conf.json tauri.conf.json.bak
# Set beforeBuildCommand to empty using PowerShell
$config = Get-Content tauri.conf.json | ConvertFrom-Json
$config.build.beforeBuildCommand = ""
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n \n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 13
"Right": 14
},
"nullable": []
},
"hash": "0fa824822cd94024dce21243f41e69e4a3e8685c4de676834a7c0ee14982c976"
"hash": "002cdb3d086f3b4181e2997befa54bd8516cb1d3d3bb6e389862695318d5c299"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n provider,\n provider_job_id,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "0848cd9ddf8f61f2dd4d57f0c58b66a60a985acaa1ee5753f30f5e6e107e080e"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n \n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "08f52f15acf6afc7091a525e3dc88f706c71c3579e5e81aa9903807d85f469ef"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n pci.media_token as `media_token: tokens::tokens::media_files::MediaFileToken`,\n pci.context_semantic_type as `context_semantic_type: enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension\n\nFROM prompt_context_items pci\nJOIN media_files m\nON\n pci.media_token = m.token\nWHERE\n pci.prompt_token = ?\nORDER BY pci.id ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "context_semantic_type: enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "094eb099cde85835db0662c2700c6b82644b51bf9b3609391ed4d2945fe5e98f"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "13ca06bcbb4a576f8a2229a8554b7488d379baa381d8d5cb60972750e81b2173"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = 'cancelled_by_system',\n retry_at = NULL,\n internal_debugging_failure_reason = 'reap_stale_fakeyou_jobs'\nWHERE\n job_type != 'fal_queue'\n AND status IN (\n 'pending',\n 'started',\n 'attempt_failed'\n )\n AND created_at > NOW() - INTERVAL 30 MINUTE\nLIMIT 5000\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "19708db99f2b3c6283d2115a511e0eb1c99fab396ad0effd9eb2dd79926e9c93"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "22b91a21cbb88abc9bc17b338cf2f9418e08e414667d44476623eb141ab17b87"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::model_type::ModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"describe": {
"columns": [
{
@@ -25,6 +25,26 @@
},
{
"ordinal": 2,
"name": "maybe_model_type: enums::common::model_type::ModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_generation_provider: enums::common::generation_provider::GenerationProvider",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -34,7 +54,7 @@
}
},
{
"ordinal": 3,
"ordinal": 5,
"name": "maybe_positive_prompt",
"type_info": {
"type": "Blob",
@@ -44,7 +64,7 @@
}
},
{
"ordinal": 4,
"ordinal": 6,
"name": "maybe_negative_prompt",
"type_info": {
"type": "Blob",
@@ -54,7 +74,7 @@
}
},
{
"ordinal": 5,
"ordinal": 7,
"name": "maybe_other_args",
"type_info": {
"type": "Blob",
@@ -64,7 +84,7 @@
}
},
{
"ordinal": 6,
"ordinal": 8,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
@@ -74,7 +94,7 @@
}
},
{
"ordinal": 7,
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -94,9 +114,11 @@
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "61744bbf83f7cf00348bcc21e868d9c506fec46da4a33ac50d63c6d9e00c0cec"
"hash": "b46b0d022790f4e10f6e47126df0fdc55172bf8cda77d4e0b2afac9e6ebd1630"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
@@ -75,6 +75,16 @@
},
{
"ordinal": 7,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -84,7 +94,7 @@
}
},
{
"ordinal": 8,
"ordinal": 9,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -105,9 +115,10 @@
true,
true,
false,
true,
false,
false
]
},
"hash": "64dfacc1f0552d539f009fea91e902a2fe296c06ad539808596bbf2c8fc9eaa0"
"hash": "fbe482c8987231c3b5d75f2e792d0fdd033de8a7eb821428fa034752c73edfb7"
}
Generated
+9 -1
View File
@@ -1918,6 +1918,13 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "cloudflare_errors"
version = "0.0.1"
dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "coarsetime"
version = "0.1.34"
@@ -6841,6 +6848,7 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"cloudflare_errors",
"cookie 0.18.1",
"errors",
"idempotency",
@@ -9634,7 +9642,6 @@ dependencies = [
"sqlx",
"tempfile",
"tokens",
"tokio",
"workspace-build-acceleration",
]
@@ -9992,6 +9999,7 @@ dependencies = [
"artcraft_api_defs",
"backoff",
"chrono",
"cloudflare_errors",
"cookie 0.18.1",
"enums",
"enums_public",
+2
View File
@@ -40,6 +40,7 @@ members = [
"crates/lib/caching/memory_caching",
"crates/lib/caching/memory_store",
"crates/lib/caching/redis_caching",
"crates/lib/cloudflare_errors",
"crates/lib/concurrency",
"crates/lib/cookies",
"crates/lib/data/collections",
@@ -216,6 +217,7 @@ url = { version = "2.5.4", features = ["serde"] }
artcraft_api_defs = { path = "crates/schema/public/api/artcraft_api_defs" }
build_metadata = { path = "crates/lib/build_metadata" }
cloud_storage = { path = "crates/api_clients/cloud_storage" }
cloudflare_errors = { path = "crates/lib/cloudflare_errors" }
crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
@@ -1,4 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- Migration history:
-- tasks_v1.sqlite - initial version
-- tasks_v2.sqlite - added model_type (nullable)
CREATE TABLE tasks (
id TEXT NOT NULL PRIMARY KEY,
@@ -6,6 +9,8 @@ CREATE TABLE tasks (
task_status TEXT NOT NULL,
task_type TEXT NOT NULL,
model_type TEXT,
provider TEXT,
provider_job_id TEXT,
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_model_type;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_model_type VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_generation_provider;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_generation_provider VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_anonymous_visitor_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_anonymous_visitor_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE prompt_context_items;
@@ -0,0 +1,30 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- An n:m join table between prompts and media_files.
-- This associates mostly image media files with prompts, though we could conceivably
-- also store 3D sets, etc.
CREATE TABLE prompt_context_items (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- The semantic type of context.
context_semantic_type VARCHAR(16) NOT NULL,
-- Foreign key, many-to-many
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
prompt_token VARCHAR(32) NOT NULL,
-- Foreign key to the media token
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
media_token VARCHAR(32) NOT NULL,
-- INDICES --
PRIMARY KEY (id),
KEY index_prompt_token (prompt_token),
KEY index_media_token (media_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs DROP COLUMN maybe_prompt_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_prompt_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files DROP COLUMN maybe_generation_provider;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN maybe_generation_provider VARCHAR(16) DEFAULT NULL;
@@ -21,6 +21,8 @@ CREATE TABLE generic_inference_jobs (
-- The new enum for the type of job, which will eventually replace `inference_category` and `maybe_model_type`.
-- This isn't fully supported yet in the inference-job, but we'll start populating it and adding logic around it.
-- We'll start this out as nullable, then migrate the old rows with a default column value.
--
-- Fal jobs have the type `fal_queue`.
job_type VARCHAR(32) DEFAULT NULL,
-- There is an index on this column.
@@ -103,6 +105,11 @@ CREATE TABLE generic_inference_jobs (
-- And furthermore, not all download URLs have a cover image.
maybe_cover_image_media_file_token VARCHAR(32) DEFAULT NULL,
-- If we pre-constructed a prompt, this will be copied to the resultant media generated.
-- This is especially important for Fal-related jobs and endpoints. We need this for keeping
-- track of all the prompt details, context images, etc.
maybe_prompt_token VARCHAR(30) DEFAULT NULL,
-- A migration flag we can control from the backend API service which will tell
-- the worker where to store the results. If false, GCP. If true, AWS.
store_in_aws BOOLEAN NOT NULL DEFAULT FALSE,
+3
View File
@@ -61,6 +61,9 @@ CREATE TABLE media_files (
-- * 'workflow' (DEPRECATED)
origin_product_category VARCHAR(16) NOT NULL DEFAULT "unknown",
-- What service provided the generation (if any).
maybe_generation_provider VARCHAR(16) DEFAULT NULL,
-- For inference that can be tied back to a model, the type of model.
-- There are multiple indices on this column!
-- DO NOT EXPOSE THIS TO USERS VIA THE API, as we may leak secrets in doing so.
@@ -15,9 +15,9 @@ CREATE TABLE batch_generations (
-- The type of record this is attached to
-- Currently supported values:
-- * 'model_weight'
-- * 'model_weight' (deprecated)
-- * 'comment' (deprecated)
-- * 'media_file'
-- * 'comment'
entity_type VARCHAR(32) NOT NULL,
-- The token for the record.
@@ -37,6 +37,4 @@ CREATE TABLE batch_generations (
-- NB: Realistically, we probably don't need this index until we have a lot of entity types, and the entity_token
-- entropy is probably sufficient enough as-is.
-- KEY index_entity_type_and_token (entity_type, entity_token),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,30 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- An n:m join table between prompts and media_files.
-- This associates mostly image media files with prompts, though we could conceivably
-- also store 3D sets, etc.
CREATE TABLE prompt_context_items (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Foreign key, many-to-many
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
prompt_token VARCHAR(32) NOT NULL,
-- Foreign key to the media token
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
media_token VARCHAR(32) NOT NULL,
-- The semantic type of context.
context_semantic_type VARCHAR(16) NOT NULL,
-- INDICES --
PRIMARY KEY (id),
KEY index_prompt_token (prompt_token),
KEY index_media_token (media_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -19,13 +19,25 @@ CREATE TABLE prompts (
-- Original product to which the prompt applied.
-- Prompts may or may not be compatible across products.
-- Possible values:
-- * 'stable_diffusion'
-- * 'comfy_ui'
-- * 'artcraft_app'
-- * 'stable_diffusion' (legacy)
-- * 'comfy_ui' (legacy)
prompt_type VARCHAR(16) NOT NULL,
-- Whomever created the prompt
-- What type of model was used to generate the result.
-- NB: We're conflating prompts with outputs, but in a
-- sense the model is inextricably part of the prompt.
maybe_model_type VARCHAR(32) DEFAULT NULL,
-- What service provided the generation.
maybe_generation_provider VARCHAR(16) NOT NULL,
-- Whoever created the prompt
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- Whoever created the prompt (anonymous user tracking)
maybe_anonymous_visitor_token VARCHAR(32) DEFAULT NULL,
-- ========== PROMPT DETAILS ==========
-- The positive prompt (optional)
@@ -0,0 +1,123 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use fal::prelude::fal_ai::gpt_image_1::edit_image::byok::{gpt_text_to_image, GptTextToImageRequest};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct GptTextToImageByokArgs<'a, V: IntoUrl> {
// Request
pub prompt: &'a str,
pub image_size: GptTextToImageSize,
pub num_images: GptTextToImageNumImages,
pub quality: GptTextToImageQuality,
// Fulfillment
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageSize{
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageNumImages{
One,
Two,
Three,
Four,
}
pub async fn enqueue_gpt_image_1_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptTextToImageSize::Auto => "auto",
GptTextToImageSize::Square => "1024x1024",
GptTextToImageSize::Horizontal => "1536x1024",
GptTextToImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptTextToImageQuality::Auto => "auto",
GptTextToImageQuality::Low => "low",
GptTextToImageQuality::Medium => "medium",
GptTextToImageQuality::High => "high",
};
let num_images = match args.num_images {
GptTextToImageNumImages::One => 1,
GptTextToImageNumImages::Two => 2,
GptTextToImageNumImages::Three => 3,
GptTextToImageNumImages::Four => 4,
};
let request = GptTextToImageRequest {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
};
let result = gpt_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::enqueue_gpt_image_1_text_to_image_webhook::{enqueue_gpt_image_1_text_to_image_webhook, GptTextToImageByokArgs, GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_gpt_image_1_edit_image() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let fal_api_key = FalApiKey::from_str(&secret);
let secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let open_ai_api_key = OpenAiApiKey::from_str(&secret);
let args = GptTextToImageByokArgs {
prompt: "put the man and the ghost on the grassy hill. the man is scared of the friendly ghost.",
api_key: &fal_api_key,
openai_api_key: &open_ai_api_key,
webhook_url: "https://example.com/webhook",
image_size: GptTextToImageSize::Horizontal,
num_images: GptTextToImageNumImages::One,
quality: GptTextToImageQuality::High,
};
let result = enqueue_gpt_image_1_text_to_image_webhook(args).await?;
Ok(())
}
}
@@ -3,4 +3,5 @@ pub mod enqueue_flux_1_schnell_text_to_image_webhook;
pub mod enqueue_flux_pro_11_text_to_image_webhook;
pub mod enqueue_flux_pro_11_ultra_text_to_image_webhook;
pub mod enqueue_gpt_image_1_edit_image_webhook;
pub mod enqueue_gpt_image_1_text_to_image_webhook;
pub mod remove_background_rembg_webhook;
@@ -37,10 +37,10 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo2Duration::Default => None,
Veo2Duration::FiveSeconds => Some("5".to_string()),
Veo2Duration::SixSeconds => Some("6".to_string()),
Veo2Duration::SevenSeconds => Some("7".to_string()),
Veo2Duration::EightSeconds => Some("8".to_string()),
Veo2Duration::FiveSeconds => Some("5s".to_string()),
Veo2Duration::SixSeconds => Some("6s".to_string()),
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
@@ -23,6 +23,7 @@ idempotency.workspace = true
anyhow.workspace = true
base64.workspace = true
chrono.workspace = true
cloudflare_errors.workspace = true
cookie.workspace = true
log.workspace = true
once_cell.workspace = true
@@ -1,3 +1,4 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
@@ -42,15 +43,9 @@ pub enum SoraError {
/// Typically served from Cloudflare
/// We preserve the message for debugging.
BadGateway(String),
/// HTTP 403 + Cloudflare Challenge Interstitial
/// Cloudflare decided to block the request and present a challenge.
CloudFlareChallenge,
/// HTTP 524 (Cloudflare-specific)
/// Cloudflare formed a TCP connection to Sora, but no payload was delivered before timeout
/// We preserve the message for debugging
CloudFlareTimeout(String),
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// Another error occurred.
OtherBadStatus(anyhow::Error),
@@ -72,6 +67,17 @@ pub enum SoraError {
JsonErrorWithBody(serde_json::Error, String),
}
impl SoraError {
pub fn is_sora_having_downtime_issues(&self) -> bool {
match self {
Self::BadGateway(_) => true,
Self::CloudflareError(CloudflareError::GatewayTimeout504) => true,
Self::CloudflareError(CloudflareError::TimeoutOccurred524) => true,
_ => false,
}
}
}
impl Error for SoraError {}
impl Display for SoraError {
@@ -98,11 +104,8 @@ impl Display for SoraError {
Self::BadGateway(message) => {
write!(f, "Bad Gateway; Sora is likely having issues: {:?}", message)
}
Self::CloudFlareChallenge => {
write!(f, "Cloudflare Challenge (403); Cloudflare wants to verify the request with a CAPTCHA challenge.")
}
Self::CloudFlareTimeout(message) => {
write!(f, "Cloudflare Timeout (524); Sora is likely having issues: {:?}", message)
Self::CloudflareError(error) => {
write!(f, "Cloudflare error: {}", error)
}
Self::OtherBadStatus(err) => {
write!(f, "Other error: {}", err)
@@ -149,3 +152,9 @@ impl From<serde_json::Error> for SoraError {
Self::JsonError(err)
}
}
impl From<CloudflareError> for SoraError {
fn from(err: CloudflareError) -> Self {
Self::CloudflareError(err)
}
}
@@ -1,6 +1,6 @@
use crate::sora_error::SoraError;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use reqwest::StatusCode;
/*
Old cookie -
@@ -50,32 +50,15 @@ pub async fn classify_general_http_status_code_and_body(status: StatusCode, resp
}
let status_code = status.as_u16();
if let Err(err) = filter_cloudflare_errors(status_code, &response_body) {
return SoraError::CloudflareError(err);
}
match status_code {
403 => {
if message.contains("challenge-platform")
|| message.contains("challenge-error-text")
|| message.contains("cType: 'managed'")
|| message.contains("Just a moment...") {
return SoraError::CloudFlareChallenge;
}
}
502 => {
return SoraError::BadGateway(message);
}
504 => {
if message.contains("cloudflare")
|| message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
}
524 => {
// NB: 524 is a cloudflare specific gateway timeout error with slightly different semantics than 504.
if message.contains("cloudflare")
|| message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
}
_ => {}, // Fall-through
}
@@ -24,12 +24,13 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
cloudflare_errors.workspace = true
enums.workspace = true
enums_public.workspace = true
errors.workspace = true
idempotency.workspace = true
tokens.workspace = true
mimetypes.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
@@ -1,7 +1,8 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use errors::AnyhowError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
use errors::AnyhowError;
#[derive(Debug)]
pub enum ApiError {
@@ -26,6 +27,9 @@ pub enum ApiError {
backend_hostname: Option<String>,
},
/// Cloudflare errors.
CloudflareError(CloudflareError),
/// A deserialization error with the response.
DeserializationError(serde_json::Error),
@@ -63,6 +67,8 @@ impl Display for ApiError {
// Network errors
ApiError::Timeout(msg) => write!(f, "Timeout: {}", msg),
ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
// Cloudflare errors
ApiError::CloudflareError(error) => write!(f, "Cloudflare Error: {}", error),
// I/O errors
ApiError::IoError(error) => write!(f, "IO error: {}", error),
// Other
@@ -95,3 +101,9 @@ impl From<io::Error> for ApiError {
ApiError::IoError(error)
}
}
impl From<CloudflareError> for ApiError {
fn from(error: CloudflareError) -> Self {
ApiError::CloudflareError(error)
}
}
@@ -0,0 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageRequest, GenerateGptImage1TextToImageResponse, GPT_IMAGE_1_TEXT_TO_IMAGE_PATH};
pub async fn generate_gpt_image_1_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateGptImage1TextToImageRequest,
) -> Result<GenerateGptImage1TextToImageResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GPT_IMAGE_1_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -3,4 +3,5 @@ pub mod generate_flux_1_dev_text_to_image;
pub mod generate_flux_1_schnell_text_to_image;
pub mod generate_flux_pro_11_text_to_image;
pub mod generate_flux_pro_11_ultra_text_to_image;
pub mod generate_gpt_image_1_text_to_image;
pub mod remove_image_background;
@@ -1,6 +1,7 @@
use crate::error::api_error::ApiError;
use crate::utils::status_codes::*;
use anyhow::anyhow;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use log::error;
/// Pass request errors to this method for standard error handling behavior.
@@ -13,30 +14,34 @@ use log::error;
pub async fn filter_bad_response(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {
let status = response.status();
if !status.is_success() {
// The host in the fleet that handled the request.
let backend_hostname= response.headers()
.get("x-backend-hostname")
.map(|header| header.to_str().unwrap_or_else(|err| {
error!("Failed to parse x-backend-hostname header: {:?}", err);
"unknown"
}))
.map(|s| s.to_string());
let response_body = match response.text().await {
Ok(text) => text.to_string(),
Err(err) => format!("Could not read response body: {:?}", err),
};
return match status.as_u16() {
STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)),
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)),
STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError { body: response_body, backend_hostname }),
_ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))),
}
if status.is_success() {
return Ok(response);
}
Ok(response)
}
// The host in the fleet that handled the request.
let backend_hostname= response.headers()
.get("x-backend-hostname")
.map(|header| header.to_str().unwrap_or_else(|err| {
error!("Failed to parse x-backend-hostname header: {:?}", err);
"unknown"
}))
.map(|s| s.to_string());
let response_body = match response.text().await {
Ok(text) => text.to_string(),
Err(err) => format!("Could not read response body: {:?}", err),
};
let status_code = status.as_u16();
filter_cloudflare_errors(status_code, &response_body)?;
match status_code {
STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)),
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)),
STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError { body: response_body, backend_hostname }),
_ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))),
}
}
@@ -1,6 +1,7 @@
use crate::core::commands::enqueue::image::generic::handle_image_artcraft::handle_image_artcraft;
use crate::core::commands::enqueue::image::generic::handle_image_fal::handle_image_fal;
use crate::core::commands::enqueue::image::gpt_image_1::handle_image_sora::handle_image_sora;
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1::handle_gpt_image_1;
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
@@ -39,6 +40,9 @@ pub struct EnqueueTextToImageRequest {
/// Aspect ratio.
pub aspect_ratio: Option<TextToImageSize>,
/// The number of images to generate.
pub number_images: Option<u32>,
}
// TODO(bt,2025-07-14): Support other aspect ratios / resolutions -
@@ -224,7 +228,16 @@ pub async fn dispatch_request(
return Err(InternalImageError::NoModelSpecified);
}
Some(ImageModel::GptImage1) => {
return handle_image_sora(&app, request, sora_creds_manager, sora_task_queue).await;
return handle_gpt_image_1(
request,
app,
app_data_root,
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
sora_creds_manager,
sora_task_queue,
).await;
}
_ => {
// Fall-through
@@ -9,10 +9,10 @@ use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::{GenerateFlux1DevTextToImageAspectRatio, GenerateFlux1DevTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageAspectRatio, GenerateFlux1SchnellTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::{GenerateFluxPro11TextToImageAspectRatio, GenerateFluxPro11TextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageAspectRatio, GenerateFluxPro11UltraTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::{GenerateFlux1DevTextToImageAspectRatio, GenerateFlux1DevTextToImageNumImages, GenerateFlux1DevTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageAspectRatio, GenerateFlux1SchnellTextToImageNumImages, GenerateFlux1SchnellTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::{GenerateFluxPro11TextToImageAspectRatio, GenerateFluxPro11TextToImageNumImages, GenerateFluxPro11TextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageAspectRatio, GenerateFluxPro11UltraTextToImageNumImages, GenerateFluxPro11UltraTextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
@@ -58,7 +58,7 @@ pub async fn handle_image_artcraft(
return Err(InternalImageError::NoModelSpecified);
}
Some(ImageModel::GptImage1) => {
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: artcraft is handling sora images")));
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: an earlier branch should have handled gpt-image-1 images for Artcraft")));
}
Some(ImageModel::Recraft3) => {
return Err(InternalImageError::AnyhowError(anyhow!("not yet implemented in Artcraft")));
@@ -66,19 +66,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::Flux1Dev) => {
info!("enqueue Flux 1 Dev");
selected_model = GenerationModel::Flux1Dev;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1DevTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1DevTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
TextToImageSize::Square => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
});
let request = GenerateFlux1DevTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1DevTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1DevTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
TextToImageSize::Square => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFlux1DevTextToImageNumImages::One),
2 => Some(GenerateFlux1DevTextToImageNumImages::Two),
3 => Some(GenerateFlux1DevTextToImageNumImages::Three),
4 => Some(GenerateFlux1DevTextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_1_dev_text_to_image(
&app_env_configs.storyteller_host,
@@ -99,19 +105,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::Flux1Schnell) => {
info!("enqueue Flux 1 Schnell");
selected_model = GenerationModel::Flux1Schnell;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1SchnellTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
TextToImageSize::Square => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
});
let request = GenerateFlux1SchnellTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1SchnellTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Square => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
TextToImageSize::Auto => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFlux1SchnellTextToImageNumImages::One),
2 => Some(GenerateFlux1SchnellTextToImageNumImages::Two),
3 => Some(GenerateFlux1SchnellTextToImageNumImages::Three),
4 => Some(GenerateFlux1SchnellTextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_1_schnell_text_to_image(
&app_env_configs.storyteller_host,
@@ -132,19 +144,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::FluxPro11) => {
info!("enqueue Flux Pro 1.1");
selected_model = GenerationModel::FluxPro11;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11TextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11TextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFluxPro11TextToImageAspectRatio::Square,
TextToImageSize::Square => GenerateFluxPro11TextToImageAspectRatio::Square,
});
let request = GenerateFluxPro11TextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11TextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11TextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Square => GenerateFluxPro11TextToImageAspectRatio::Square,
TextToImageSize::Auto => GenerateFluxPro11TextToImageAspectRatio::Square,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFluxPro11TextToImageNumImages::One),
2 => Some(GenerateFluxPro11TextToImageNumImages::Two),
3 => Some(GenerateFluxPro11TextToImageNumImages::Three),
4 => Some(GenerateFluxPro11TextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_pro_11_text_to_image(
&app_env_configs.storyteller_host,
@@ -165,19 +183,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::FluxPro11Ultra) => {
info!("enqueue Flux Pro 1.1 Ultra");
selected_model = GenerationModel::FluxPro11Ultra;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
TextToImageSize::Square => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
});
let request = GenerateFluxPro11UltraTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Square => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
TextToImageSize::Auto => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFluxPro11UltraTextToImageNumImages::One),
2 => Some(GenerateFluxPro11UltraTextToImageNumImages::Two),
3 => Some(GenerateFluxPro11UltraTextToImageNumImages::Three),
4 => Some(GenerateFluxPro11UltraTextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_pro_11_ultra_text_to_image(
&app_env_configs.storyteller_host,
@@ -0,0 +1,75 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1_artcraft::handle_gpt_image_1_artcraft;
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_artcraft::handle_gpt_image_1_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_sora::handle_gpt_image_1_edit_sora;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::model::image_models::ImageModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use std::time::Duration;
use tauri::AppHandle;
pub async fn handle_gpt_image_1(
request: EnqueueTextToImageRequest,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
let priority = provider_priority_store.get_priority()?;
// TODO: Check if providers are available before proceeding.
info!("Providers by priority: {:?}", priority);
for provider in priority.iter() {
match provider {
Provider::Fal => {
// Fallthrough
// If in the future we support OpenAI API keys, it's worth considering whether we
// send those requests ourselves, or if we use FAL as an intermediary. FAL makes
// the API nicer to deal with, but the user needs an additional key.
}
Provider::Artcraft => {
info!("Dispatching gpt-image-1 via Artcraft...");
return handle_gpt_image_1_artcraft(
request,
app_env_configs,
storyteller_creds_manager
).await;
}
Provider::Sora => {
info!("Dispatching gpt-image-1 via Sora...");
return handle_gpt_image_1_sora(
request,
app,
sora_creds_manager,
sora_task_queue,
).await;
}
}
}
Err(InternalImageError::NoProviderAvailable)
}
@@ -0,0 +1,115 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageQuality, EditImageSize};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::model::image_models::ImageModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use std::time::Duration;
use storyteller_client::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use storyteller_client::generate::image::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image;
use tauri::AppHandle;
pub async fn handle_gpt_image_1_artcraft(
request: EnqueueTextToImageRequest,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(InternalImageError::NeedsStorytellerCredentials);
},
};
info!("Calling Artcraft gpt-image-1 ...");
let uuid_idempotency_token = generate_random_uuid();
let image_quality = Some(GenerateGptImage1TextToImageImageQuality::High);
//let image_quality = request.image_quality
// .map(|quality| match quality {
// EditImageQuality::Auto => GenerateGptImage1TextToImageImageQuality::Auto,
// EditImageQuality::High => GenerateGptImage1TextToImageImageQuality::High,
// EditImageQuality::Medium => GenerateGptImage1TextToImageImageQuality::Medium,
// EditImageQuality::Low => GenerateGptImage1TextToImageImageQuality::Low,
// });
let image_size = request.aspect_ratio
.map(|size| match size {
TextToImageSize::Auto => GenerateGptImage1TextToImageImageSize::Square,
TextToImageSize::Square => GenerateGptImage1TextToImageImageSize::Square,
TextToImageSize::Tall => GenerateGptImage1TextToImageImageSize::Vertical,
TextToImageSize::Wide => GenerateGptImage1TextToImageImageSize::Horizontal,
});
let num_images = match request.number_images {
None => None,
Some(1) => Some(GenerateGptImage1TextToImageNumImages::One),
Some(2) => Some(GenerateGptImage1TextToImageNumImages::Two),
Some(3) => Some(GenerateGptImage1TextToImageNumImages::Three),
Some(4) => Some(GenerateGptImage1TextToImageNumImages::Four),
// TODO: Error
//Some(other) => {
// return Err(InternalImageError::InvalidNumberOfRequestedImages {
// min: 1,
// max: 4,
// requested: other,
// });
//},
_ => Some(GenerateGptImage1TextToImageNumImages::One), // Default to one image if invalid number
};
let request = GenerateGptImage1TextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_size,
num_images,
image_quality,
};
let result = generate_gpt_image_1_text_to_image(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_id = match result {
Ok(enqueued) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued Artcraft gpt-image-1. Job token: {}",
enqueued.inference_job_token);
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft gpt-image-1: {:?}", err);
return Err(InternalImageError::StorytellerError(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
model: Some(GenerationModel::GptImage1),
provider_job_id: Some(job_id.to_string()),
task_type: TaskType::ImageGeneration,
})
}
@@ -19,9 +19,9 @@ use tauri::AppHandle;
const SORA_SIMPLE_IMAGE_GEN_TIMEOUT : Duration = Duration::from_millis(1000 * 30); // 30 seconds
pub async fn handle_image_sora(
app: &AppHandle,
pub async fn handle_gpt_image_1_sora(
request: EnqueueTextToImageRequest,
app: &AppHandle,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
@@ -55,10 +55,19 @@ pub async fn handle_image_sora(
Some(TextToImageSize::Wide) => ImageSize::Wide,
};
let num_images = match request.number_images {
None => NumImages::One,
Some(1) => NumImages::One,
Some(2) => NumImages::Two,
Some(3) => NumImages::Two, // NB: There is no option for three images!
Some(4) => NumImages::Four,
_ => NumImages::One,
};
let result =
simple_image_gen_with_session_auto_renew(SimpleImageGenAutoRenewRequest {
prompt: prompt.to_string(),
num_images: NumImages::One,
num_images,
image_size: size,
credentials: &creds,
request_timeout: Some(SORA_SIMPLE_IMAGE_GEN_TIMEOUT),
@@ -1 +1,3 @@
pub mod handle_image_sora;
pub (crate) mod handle_gpt_image_1_artcraft;
pub (crate) mod handle_gpt_image_1_sora;
pub (super) mod handle_gpt_image_1;
@@ -3,7 +3,7 @@ use crate::core::commands::enqueue::image::internal_image_error::InternalImageEr
use crate::core::commands::enqueue::image_bg_removal::errors::InternalBgRemovalError;
use crate::core::commands::enqueue::image_bg_removal::generic::handle_generic_bg_removal::handle_generic_bg_removal;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::handle_gpt_image_1;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::handle_gpt_image_1_edit;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType};
@@ -4,8 +4,8 @@ use crate::core::commands::enqueue::image_bg_removal::generic::handle_generic_bg
use crate::core::commands::enqueue::image_bg_removal::generic::handle_generic_bg_removal_fal::handle_generic_bg_removal_fal;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::EnqueueContextualEditImageCommand;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_artcraft::handle_gpt_image_1_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_artcraft::handle_gpt_image_1_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_sora::handle_gpt_image_1_edit_sora;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
@@ -1,7 +1,7 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, EnqueueTextToImageSuccessResponse};
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::handle_gpt_image_1;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::handle_gpt_image_1_edit;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType};
@@ -208,7 +208,7 @@ pub async fn handle_request(
return Err(InternalContextualEditImageError::NoModelSpecified)
}
Some(ContextualImageEditModel::GptImage1) => {
handle_gpt_image_1(
handle_gpt_image_1_edit(
request,
app,
app_data_root,
@@ -1,7 +1,7 @@
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::EnqueueContextualEditImageCommand;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_artcraft::handle_gpt_image_1_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_artcraft::handle_gpt_image_1_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_sora::handle_gpt_image_1_edit_sora;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
@@ -16,7 +16,7 @@ use tauri::AppHandle;
pub(super) const MAX_IMAGES: usize = 10;
pub async fn handle_gpt_image_1(
pub async fn handle_gpt_image_1_edit(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
@@ -44,8 +44,8 @@ pub async fn handle_gpt_image_1(
// the API nicer to deal with, but the user needs an additional key.
}
Provider::Artcraft => {
info!("Dispatching gpt-image-1 via Artcraft...");
return handle_gpt_image_1_artcraft(
info!("Dispatching gpt-image-1 (edit) via Artcraft...");
return handle_gpt_image_1_edit_artcraft(
request,
app,
app_data_root,
@@ -54,8 +54,8 @@ pub async fn handle_gpt_image_1(
).await;
}
Provider::Sora => {
info!("Dispatching gpt-image-1 via Sora...");
return handle_gpt_image_1_sora(
info!("Dispatching gpt-image-1 (edit) via Sora...");
return handle_gpt_image_1_edit_sora(
request,
app,
app_data_root,
@@ -2,7 +2,7 @@ use crate::core::commands::enqueue::image::enqueue_text_to_image_command::Enqueu
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageQuality, EditImageSize, EnqueueContextualEditImageCommand};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::MAX_IMAGES;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::MAX_IMAGES;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
@@ -36,7 +36,7 @@ use tauri::AppHandle;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
pub async fn handle_gpt_image_1_artcraft(
pub async fn handle_gpt_image_1_edit_artcraft(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
@@ -51,7 +51,7 @@ pub async fn handle_gpt_image_1_artcraft(
},
};
info!("Calling Artcraft gpt-image-1 ...");
info!("Calling Artcraft gpt-image-1 (edit) ...");
let uuid_idempotency_token = generate_random_uuid();
@@ -2,7 +2,7 @@ use crate::core::commands::enqueue::image::enqueue_text_to_image_command::Enqueu
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageSize, EnqueueContextualEditImageCommand, EnqueueContextualEditImageErrorType};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::MAX_IMAGES;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::MAX_IMAGES;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
@@ -48,7 +48,7 @@ const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); //
const DEFAULT_ASPECT_RATIO : EditImageSize = EditImageSize::Square;
pub async fn handle_gpt_image_1_sora(
pub async fn handle_gpt_image_1_edit_sora(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
@@ -1,3 +1,3 @@
pub mod handle_gpt_image_1;
pub mod handle_gpt_image_1_artcraft;
pub mod handle_gpt_image_1_sora;
pub mod handle_gpt_image_1_edit;
pub mod handle_gpt_image_1_edit_artcraft;
pub mod handle_gpt_image_1_edit_sora;
@@ -1,6 +1,7 @@
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::state::task_database::TaskDatabase;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use sqlite_tasks::error::SqliteTasksError;
@@ -34,10 +35,33 @@ impl TaskEnqueueSuccess{
}
pub async fn insert_into_task_database(&self, task_database: &TaskDatabase) -> Result<TaskId, SqliteTasksError> {
// TODO: Move this mapping elsewhere, or remove the other models.
let model_type = match self.model {
None => None,
Some(GenerationModel::Flux1Dev) => Some(TaskModelType::Flux1Dev),
Some(GenerationModel::Flux1Schnell) => Some(TaskModelType::Flux1Schnell),
Some(GenerationModel::FluxPro11) => Some(TaskModelType::FluxPro11),
Some(GenerationModel::FluxPro11Ultra) => Some(TaskModelType::FluxPro11Ultra),
Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1),
Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3),
Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro),
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
Some(GenerationModel::Hunyuan3d2_0) => Some(TaskModelType::Hunyuan3d2_0),
Some(GenerationModel::Hunyuan3d2_1) => Some(TaskModelType::Hunyuan3d2_1),
// TODO: These seem wrong -
Some(GenerationModel::Kling1_6) => Some(TaskModelType::Kling16Pro), // NB: `VideoModel::Kling16Pro`.
Some(GenerationModel::Kling2_0) => None, // TODO: unused elsewhere?
Some(GenerationModel::Sora) => None, // TODO: unused elsewhere?
};
create_task(CreateTaskArgs {
db: task_database.get_connection(),
status: TaskStatus::Pending,
task_type: self.task_type,
model_type: model_type,
provider: self.provider,
provider_job_id: self.provider_job_id.as_deref(),
frontend_subscriber_id: None,
@@ -0,0 +1,43 @@
use futures::TryFutureExt;
use log::{error, info};
use reqwest::Client;
use tauri::ipc::Response;
/// Request a URL and load it without worrying about CORS.
#[tauri::command]
pub async fn load_without_cors_command(url: String) -> Result<Response, String> {
info!("load_without_cors_command for URL: {}", url);
// TODO(bt,2025-07-22): Make sure the URL is valid and safe to fetch. Check an allowlist.
// TODO(bt,2025-07-22): Should we cache clients so we don't need to SSL handshake every time?
let client = Client::builder()
.gzip(true)
.build()
.map_err(|e| {
error!("Failed to create HTTP client: {:?}", e);
format!("Failed to create HTTP client: {:?}", e)
})?;
let response = client.get(url.clone())
.send()
.await
.map_err(|e| {
error!("Failed to send HTTP request: {:?}", e);
format!("Failed to send HTTP request: {:?}", e)
})?;
if response.status() != 200 {
return Err(format!("Failed to fetch URL: {}. Status: {}", url, response.status()));
}
let bytes = response.bytes()
.await
.map_err(|e| {
error!("Failed to read response bytes: {:?}", e);
format!("Failed to read response bytes: {:?}", e)
})?;
Ok(Response::new(bytes.to_vec()))
}
@@ -2,6 +2,7 @@ pub mod app_preferences;
pub mod enqueue;
pub mod flip_image;
pub mod get_app_info_command;
pub mod load_without_cors_command;
pub mod platform_info_command;
pub mod providers;
pub mod response;
@@ -25,8 +25,11 @@ pub enum GenerationModel {
Hunyuan3d2_0,
#[serde(rename = "hunyuan_3d_2_1")]
Hunyuan3d2_1,
// TODO: Should be Kling16Pro
#[serde(rename = "kling_1.6")]
Kling1_6,
#[serde(rename = "kling_2.0")]
Kling2_0,
#[serde(rename = "kling_2.1_master")]
@@ -1,7 +1,18 @@
use crate::core::lifecycle::startup::bootstrap_task_database::bootstrap_task_database;
use crate::core::lifecycle::startup::spawn_storytoller_task_polling_thread::spawn_storyteller_task_polling_thread;
use crate::core::lifecycle::startup::tasks::bootstrap_task_database::bootstrap_task_database;
use crate::core::lifecycle::startup::tasks::load_provider_priority_state::load_provider_priority_state;
use crate::core::lifecycle::startup::tasks::set_app_log_level::set_app_log_level;
use crate::core::lifecycle::startup::tasks::initially_size_and_position_windows::initially_size_and_position_windows;
use crate::core::lifecycle::startup::tasks::spawn_discord_presence_thread::spawn_discord_presence_thread;
use crate::core::lifecycle::startup::tasks::spawn_fal_task_polling_thread::spawn_fal_task_polling_thread;
use crate::core::lifecycle::startup::tasks::spawn_main_window_thread::spawn_main_window_thread;
use crate::core::lifecycle::startup::tasks::spawn_sora_task_polling_thread::spawn_sora_task_polling_thread;
use crate::core::lifecycle::startup::tasks::spawn_storytoller_task_polling_thread::spawn_storyteller_task_polling_thread;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
@@ -11,8 +22,30 @@ pub async fn handle_tauri_startup(
root: AppDataRoot,
app_env_configs: AppEnvConfigs,
storyteller_creds_manager: StorytellerCredentialManager,
sora_credential_manager: SoraCredentialManager,
sora_task_queue: SoraTaskQueue,
fal_credential_manager: FalCredentialManager,
fal_task_queue: FalTaskQueue,
) -> AnyhowResult<()> {
let task_database = bootstrap_task_database(&app, &root).await?;
set_app_log_level(
&app,
&root,
)?;
let task_database =
bootstrap_task_database(&app, &root).await?;
load_provider_priority_state(
&app,
&root,
)?;
spawn_main_window_thread(
&app,
&root,
&storyteller_creds_manager,
)?;
spawn_storyteller_task_polling_thread(
&app,
@@ -21,5 +54,27 @@ pub async fn handle_tauri_startup(
&storyteller_creds_manager,
)?;
spawn_sora_task_polling_thread(
&app,
&root,
&app_env_configs,
&sora_credential_manager,
&storyteller_creds_manager,
&sora_task_queue,
)?;
spawn_fal_task_polling_thread(
&app,
&root,
&app_env_configs,
&fal_credential_manager,
&fal_task_queue,
&storyteller_creds_manager,
)?;
spawn_discord_presence_thread()?;
initially_size_and_position_windows(&app, &root);
Ok(())
}
@@ -1,3 +1,2 @@
pub mod bootstrap_task_database;
pub mod handle_tauri_startup;
mod spawn_storytoller_task_polling_thread;
mod tasks;
@@ -0,0 +1,42 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::window::main_window_position::MainWindowPosition;
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::utils::window::position_main_window::position_main_window;
use crate::core::utils::window::resize_main_window::resize_main_window;
use tauri::AppHandle;
pub fn initially_size_and_position_windows(
app: &AppHandle,
root: &AppDataRoot,
) {
println!("Sizing and positioning window...");
match MainWindowSize::from_filesystem_configs(&root) {
Ok(None) => {}
Ok(Some(size)) => {
println!("Resizing window to: {:?}", size);
let result = resize_main_window(app, &size);
if let Err(err) = result {
eprintln!("Could not set window size: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window size from disk: {:?}", err);
}
}
match MainWindowPosition::from_filesystem_configs(&root) {
Ok(None) => {}
Ok(Some(pos)) => {
println!("Moving window to: {:?}", pos);
let result = position_main_window(app, &pos);
if let Err(err) = result {
eprintln!("Could not set window position: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window position from disk: {:?}", err);
}
}
}
@@ -0,0 +1,34 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use anyhow::anyhow;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn load_provider_priority_state(
app: &AppHandle,
root: &AppDataRoot,
) -> AnyhowResult<()> {
let provider_priority = match ProviderPriorityStore::from_filesystem_configs(root) {
Ok(Some(priority)) => {
println!("Loaded provider priority from disk: {:?}", priority.get_priority());
priority
}
Ok(None) => {
println!("No provider priority found on disk, using default.");
ProviderPriorityStore::default()
}
Err(err) => {
eprintln!("Failed to read provider priority from disk: {:?}", err);
ProviderPriorityStore::default()
}
};
let success = app.manage(provider_priority);
if !success {
eprintln!("Failed to manage provider priority state.");
return Err(anyhow!("Failed to manage provider priority state."));
}
Ok(())
}
@@ -0,0 +1,9 @@
pub (super) mod bootstrap_task_database;
pub (super) mod set_app_log_level;
pub (super) mod initially_size_and_position_windows;
pub (super) mod spawn_discord_presence_thread;
pub (super) mod spawn_fal_task_polling_thread;
pub (super) mod spawn_main_window_thread;
pub (super) mod spawn_sora_task_polling_thread;
pub (super) mod spawn_storytoller_task_polling_thread;
pub (super) mod load_provider_priority_state;
@@ -0,0 +1,73 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use errors::AnyhowResult;
use log::LevelFilter;
use std::env;
use std::env::VarError;
use tauri::AppHandle;
use tauri_plugin_log::{Target, TargetKind};
const RUST_LOG: &str = "RUST_LOG";
pub fn set_app_log_level(
app: &AppHandle,
root: &AppDataRoot,
) -> AnyhowResult<()> {
//print_env_var(RUST_LOG);
// NB: The semantics of this do not match `RUST_LOG` exactly as it's not set per-package.
let maybe_level = env::var(RUST_LOG)
.map(|value| value.trim().to_ascii_lowercase());
let mut log_level = LevelFilter::Info;
match maybe_level {
Ok(level) => {
println!("The `RUST_LOG` environment variable was set to: `{}`.", level);
if level.contains("debug") {
log_level = LevelFilter::Debug;
} else if level.contains("info") {
log_level = LevelFilter::Info;
} else if level.contains("warn") {
log_level = LevelFilter::Warn;
} else if level.contains("error") {
log_level = LevelFilter::Error;
} else if level.contains("off") {
log_level = LevelFilter::Off;
}
}
Err(VarError::NotPresent) => {
// NB: This demonstrably tests stderr output in the default case.
eprintln!("No `RUST_LOG` environment variable found.");
}
Err(VarError::NotUnicode(_)) => {
// NB: This demonstrably tests stderr output in the default case.
eprintln!("Could not parse `RUST_LOG` environment variable; not unicode.");
}
}
println!("Setting app log level to: {:?}", log_level);
app.plugin(
tauri_plugin_log::Builder::default()
.level(log_level)
.targets(vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir {
file_name: Some(root.log_file_name_str().to_string())
}),
])
.build(),
)?;
Ok(())
}
// pub fn print_env_var(var_name: &str) {
// match env::var(var_name) {
// Ok(value) => println!("Environment variable `{}` = {}", var_name, value),
// Err(VarError::NotPresent) => println!("Environment variable `{}` is not set.", var_name),
// Err(VarError::NotUnicode(_)) => println!("Environment variable `{}` is not a valid Unicode string", var_name),
// }
// }
@@ -0,0 +1,10 @@
use crate::core::threads::discord_presence_thread::discord_presence_thread;
use errors::AnyhowResult;
pub fn spawn_discord_presence_thread(
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(discord_presence_thread());
Ok(())
}
@@ -0,0 +1,29 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::fal::threads::fal_task_polling_thread::fal_task_polling_thread;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn spawn_fal_task_polling_thread(
app: &AppHandle,
root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
fal_credential_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(fal_task_polling_thread(
app.clone(),
app_env_configs.clone(),
root.clone(),
fal_credential_manager.clone(),
fal_task_queue.clone(),
storyteller_creds_manager.clone(),
));
Ok(())
}
@@ -0,0 +1,20 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::threads::main_window_thread::main_window_thread::main_window_thread;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn spawn_main_window_thread(
app: &AppHandle,
root: &AppDataRoot,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(main_window_thread(
app.clone(),
root.clone(),
storyteller_creds_manager.clone(),
));
Ok(())
}
@@ -0,0 +1,29 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::sora::threads::sora_task_polling_thread::sora_task_polling_thread;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn spawn_sora_task_polling_thread(
app: &AppHandle,
root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
sora_credential_manager: &SoraCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(sora_task_polling_thread(
app.clone(),
app_env_configs.clone(),
root.clone(),
sora_credential_manager.clone(),
storyteller_creds_manager.clone(),
sora_task_queue.clone(),
));
Ok(())
}
@@ -22,7 +22,7 @@ impl DataSubdir for AppStateDir{
impl AppStateDir {
pub fn get_tasks_sqlite_database_path(&self) -> PathBuf {
self.path.join("tasks_v1.sqlite")
self.path.join("tasks_v2.sqlite")
}
pub fn get_window_size_config_file(&self) -> PathBuf {
@@ -3,8 +3,7 @@ pub mod app_preferences;
pub mod app_startup_time;
pub mod data_dir;
pub mod expanduser;
pub mod main_window_position;
pub mod main_window_size;
pub mod os_platform;
pub mod provider_priority;
pub mod task_database;
pub mod task_database;
pub mod window;
@@ -42,14 +42,6 @@ impl MainWindowPosition {
Ok(Some(pos))
}
pub fn apply_to_main_window(&self, app: &AppHandle) -> AnyhowResult<()> {
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
window.set_position(PhysicalPosition::new(self.x, self.y))?;
Ok(())
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_window_position_config_file();
let json = serde_json::to_string(self)?;
@@ -1,12 +1,13 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::best_window_size_heuristic::best_window_size_heuristic;
use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME;
use crate::core::utils::window::get_window_size_heuristic::get_window_size_heuristic;
use crate::core::windows::main_window::constants::{MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_NAME};
use errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use tauri::{AppHandle, Manager, PhysicalSize, Window};
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct MainWindowSize {
pub width: u32,
@@ -26,7 +27,7 @@ impl MainWindowSize {
}
pub fn from_window(window: &Window) -> AnyhowResult<Self> {
let size = best_window_size_heuristic(window)?;
let size = get_window_size_heuristic(window)?;
Ok(Self {
width: size.width,
height: size.height,
@@ -43,14 +44,6 @@ impl MainWindowSize {
Ok(Some(size))
}
pub fn apply_to_main_window(&self, app: &AppHandle) -> AnyhowResult<()> {
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
window.set_size(PhysicalSize::new(self.width, self.height))?;
Ok(())
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_window_size_config_file();
let json = serde_json::to_string(self)?;
@@ -64,6 +57,21 @@ impl MainWindowSize {
Ok(())
}
/// Window size is not wide enough to persist or apply.
pub fn is_not_wide_enough(&self) -> bool {
self.width < MAIN_WINDOW_MIN_WIDTH
}
/// Window size is not tall enough to persist or apply.
pub fn is_not_tall_enough(&self) -> bool {
self.height < MAIN_WINDOW_MIN_HEIGHT
}
/// Window size is not big enough (tall or wide) to persist or apply.
pub fn is_not_big_enough(&self) -> bool {
self.is_not_wide_enough() || self.is_not_tall_enough()
}
pub fn to_physical_size(&self) -> PhysicalSize<u32> {
PhysicalSize::new(self.width, self.height)
}
@@ -0,0 +1,2 @@
pub mod main_window_position;
pub mod main_window_size;
@@ -1,10 +0,0 @@
pub mod frontend_subscriber_id;
pub mod frontend_subscriber_payload;
pub mod task_provider;
pub mod task;
pub mod task_id;
pub mod task_pool;
pub mod task_type;
mod task_provider_job_id;
mod task_state;
pub mod task_database;
@@ -1,42 +0,0 @@
use multi_index_map::MultiIndexMap;
use crate::core::tasks::task_id::TaskId;
use crate::core::tasks::task_provider::TaskProvider;
use crate::core::tasks::task_provider_job_id::TaskProviderJobId;
use crate::core::tasks::task_state::TaskState;
use crate::core::tasks::task_type::TaskType;
/// The provider's native identifier for the task.
pub type ProviderTaskId = String;
#[derive(MultiIndexMap, Debug, Clone)]
#[multi_index_derive(Debug)]
#[multi_index_hash(rustc_hash::FxBuildHasher)]
pub struct Task {
#[multi_index(hashed_unique)]
pub id: TaskId,
#[multi_index(hashed_non_unique)]
pub task_state: TaskState,
pub task_type: Option<TaskType>,
#[multi_index(hashed_non_unique)]
pub provider: Option<TaskProvider>,
/// Identifier used by the provider to track the job.
#[multi_index(hashed_unique)]
pub provider_job_id: Option<TaskProviderJobId>,
/// If the frontend is subscribing, it might have an identifier
pub frontend_subscriber_id: Option<String>,
/// If the frontend is subscribing, it might have a payload.
/// This is opaque, but could be for instance base64-encoded JSON.
pub frontend_subscriber_payload: Option<String>,
// TODO: Submitted and completion datetimes
// TODO: Progress
// TODO: Completion media file, completion batch token
}
@@ -1,21 +0,0 @@
pub struct TaskDatabase {
pub pool: Pool<Sqlite>,
}
impl Database {
pub async fn new(app_handle: &AppHandle) -> Result<Self> {
let app_dir = app_handle
.path_resolver()
.app_data_dir()
.expect("failed to get app dir");
// Ensure the app directory exists
fs::create_dir_all(&app_dir)?;
let db_path = app_dir.join("grid_search.db");
// Set the DATABASE_URL environment variable to point to this SQLite file
env::set_var("DATABASE_URL", format!("sqlite://{}", db_path.display()));
}
}
@@ -1,25 +0,0 @@
use idempotency::uuid::generate_random_uuid;
/// Our identifier for tasks.
/// These are ephemeral (for now), but may be peristed later on.
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct TaskId(String);
impl TaskId {
pub fn generate() -> Self {
let task_id = format!("tauri_task_{}", generate_random_uuid());
Self(task_id)
}
pub fn from_str(task_id: &str) -> Self {
Self(task_id.to_string())
}
pub fn to_string(&self) -> String {
self.0.clone()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
@@ -1,7 +0,0 @@
/// Hand-rolling this instead of SQLite for now.
/// Not sure if SQLite will create crazy build issues.
/// Can replace with SQLite later, perhaps.
pub struct TaskPool {
}
@@ -1,8 +0,0 @@
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskProvider {
Artcraft,
Fal,
Sora,
}
@@ -1,22 +0,0 @@
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct TaskProviderJobId(String);
impl TaskProviderJobId {
pub fn from_str(job_id: &str) -> Self {
Self(job_id.to_string())
}
pub fn from_artcraft_job_id(job_id: InferenceJobToken) -> Self {
Self(job_id.to_string())
}
pub fn to_string(&self) -> String {
self.0.clone()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
@@ -1,11 +0,0 @@
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskState {
Submitted,
InProgress,
// Terminal states
Complete,
Failed,
}
@@ -1,7 +0,0 @@
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskType {
BackgroundRemoval,
ImageGeneration,
VideoGeneration,
}
@@ -1,7 +1,7 @@
use crate::core::state::app_startup_time::AppStartupTime;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::main_window_size::MainWindowSize;
use crate::core::state::window::main_window_position::MainWindowPosition;
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::persist_storyteller_cookies_task;
use crate::core::threads::main_window_thread::persist_window_position_task::persist_window_position_task;
use crate::core::threads::main_window_thread::persist_window_resize_task::persist_window_resize_task;
@@ -1,5 +1,5 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::window::main_window_position::MainWindowPosition;
use errors::AnyhowResult;
use log::info;
use memory_store::clone_slot::CloneSlot;
@@ -1,6 +1,6 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_size::MainWindowSize;
use crate::core::utils::best_window_size_heuristic::best_window_size_heuristic;
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::utils::window::get_window_size_heuristic::get_window_size_heuristic;
use errors::AnyhowResult;
use log::info;
use memory_store::clone_slot::CloneSlot;
@@ -13,7 +13,7 @@ pub async fn persist_window_resize_task(
) -> AnyhowResult<()> {
let current_window_size = MainWindowSize::from_window(window)?;
let current_physical_size = best_window_size_heuristic(window)?;
let current_physical_size = get_window_size_heuristic(window)?;
let mut save_size_to_disk = true;
@@ -22,6 +22,10 @@ pub async fn persist_window_resize_task(
save_size_to_disk = false;
}
}
if current_window_size.is_not_big_enough() {
save_size_to_disk = false;
}
if save_size_to_disk {
info!("Saving window size configs to disk...");
@@ -1,4 +1,3 @@
pub mod best_window_size_heuristic;
pub mod clear_all_webview_cookies;
pub mod download_media_file_to_temp_dir;
pub mod download_url_to_temp_dir;
@@ -10,4 +9,4 @@ pub mod object_store;
pub mod save_base64_image_to_temp_dir;
pub mod simple_http_download;
pub mod simple_http_download_to_tempfile;
pub mod webview_unsafe;
pub mod window;
@@ -1,38 +0,0 @@
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
const MAIN_WINDOW_NAME: &str = "main";
pub fn webview_unsafe_for_app(app: &AppHandle) -> AnyhowResult<()> {
let windows = app.windows();
let maybe_window = windows.get(MAIN_WINDOW_NAME);
/*if let Some(window) = maybe_window {
let webview = window.get_webview(MAIN_WINDOW_NAME);
if let Some(webview) = webview {
webview_unsafe(&webview);
}
} else {
return Err(anyhow::anyhow!("Main window not found"));
}*/
Ok(())
}
/*pub fn webview_unsafe(webview: &Webview) {
let r = webview.with_webview(|webview| {
webview.controller();
#[cfg(target_os = "macos")]
unsafe {
let view: &objc2_web_kit::WKWebView = &*webview.inner().cast();
let controller: &objc2_web_kit::WKUserContentController = &*webview.controller().cast();
let window: &objc2_app_kit::NSWindow = &*webview.ns_window().cast();
//view.setPageZoom(40.);
//controller.removeAllUserScripts();
let bg_color = objc2_app_kit::NSColor::colorWithDeviceRed_green_blue_alpha(1.0, 0.2, 0.4, 1.);
window.setBackgroundColor(Some(&bg_color));
}
});
}*/
@@ -4,18 +4,18 @@ use tauri::{PhysicalSize, Window};
// A typical height difference when dev tools are open:
// Window inner size = PhysicalSize { width: 3704, height: 732 }
// Window outer size = PhysicalSize { width: 3704, height: 1476 }
const HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300;
const DEV_TOOLS_HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300;
/// Get the best window size
pub fn best_window_size_heuristic(window: &Window) -> AnyhowResult<PhysicalSize<u32>> {
pub fn get_window_size_heuristic(window: &Window) -> AnyhowResult<PhysicalSize<u32>> {
// NB: Ordinarily we'd use inner size, as the outer size includes the window decorations.
// However, dev tools contract the inner size, and in the event they're open, we should
// prefer the outer size.
let inner = window.inner_size()?;
let outer = window.outer_size()?;
let height_delta = outer.height.saturating_sub(inner.height);
if height_delta > HEIGHT_DIFFERENCE_THRESHOLD {
if height_delta > DEV_TOOLS_HEIGHT_DIFFERENCE_THRESHOLD {
// Dev tools are open, use the outer size
Ok(outer)
} else {
@@ -0,0 +1,3 @@
pub mod get_window_size_heuristic;
pub mod position_main_window;
pub mod resize_main_window;
@@ -0,0 +1,65 @@
use crate::core::state::window::main_window_position::MainWindowPosition;
use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager, PhysicalPosition};
/// Try to position the main window at startup.
/// If it doesn't meet certain placement heuristics, abandon manual placement and let the OS
/// decide where to place the app.
pub fn position_main_window(
app: &AppHandle,
position: &MainWindowPosition,
) -> AnyhowResult<()> {
// NB: This non-negative position heuristic is good for MacBook (single screen).
// Not sure how this fares on Windows, Linux, or multiscreen setups.
// On Mac, this prevents the windows from being positioned off to the top-left,
// as (0,0) is the top left directly under the top menu bar.
if position.x < 0 {
// TODO(bt,2025-07-16): just in the case of x/width, allow slight placement off
// screen as long as the app is wide enough.
return Err(anyhow::anyhow!("X position must be non-negative"));
} else if position.y < 0 {
return Err(anyhow::anyhow!("Y position must be non-negative"));
}
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
if let Ok(Some(monitor)) = window.current_monitor() {
// Reports the size of the monitor, e.g. PhysicalSize { width: 4112, height: 2658 }
// This is not impacted by `scale_factor`, even if that's a multiple like "2".
// i.e., positioning the app at `monitor.size().width/2` and `monitor.size().height/2`
// will place the **top left corner of the app** in roughly the center of the monitor.
let monitor_size = monitor.size();
println!("Current monitor - size: {:?}", monitor_size);
println!("Current monitor - scale factor: {:?}", monitor.scale_factor());
println!("Current monitor - position: {:?}", monitor.position());
let width_padding = monitor_size.width.div_ceil(6);
let height_padding = monitor_size.height.div_ceil(6);
let max_width = monitor_size.width.saturating_sub(width_padding);
let max_height = monitor_size.height.saturating_sub(height_padding);
let max_width = i32::try_from(max_width)?;
let max_height = i32::try_from(max_height)?;
// Prevent the window from being positioned off the screen to the bottom, right, and bottom right.
// This works on MacBook (single screen), but should be tested on Windows, Linux, and multiscreen setups.
if position.x > max_width {
return Err(anyhow::anyhow!("X position must be less than or equal to {} (monitor width {})",
max_width, monitor_size.width));
} else if position.y > max_height {
return Err(anyhow::anyhow!("Y position must be less than or equal to {} (monitor height {})",
max_height, monitor_size.height));
}
}
window.set_position(PhysicalPosition::new(position.x, position.y))?;
Ok(())
}
@@ -0,0 +1,63 @@
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::windows::main_window::constants::{MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_NAME};
use tauri::{AppHandle, Manager, PhysicalSize};
/// Try to resize the main window at startup.
/// If it doesn't meet certain sizing heuristics, abandon manual resize and let the OS
/// decide how to size the app.
pub fn resize_main_window(
app: &AppHandle,
size: &MainWindowSize,
) -> errors::AnyhowResult<()> {
let mut resize_width = size.width;
let mut resize_height = size.height;
let mut resize_was_corrected = false;
if resize_width < MAIN_WINDOW_MIN_WIDTH {
resize_width = MAIN_WINDOW_MIN_WIDTH;
resize_was_corrected = true;
}
if resize_height < MAIN_WINDOW_MIN_HEIGHT {
resize_height = MAIN_WINDOW_MIN_HEIGHT;
resize_was_corrected = true;
}
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
if let Ok(Some(monitor)) = window.current_monitor() {
let scale_factor = monitor.scale_factor();
if scale_factor > 1.0 {
let min_width = MAIN_WINDOW_MIN_WIDTH as f64 * scale_factor;
let min_height = MAIN_WINDOW_MIN_HEIGHT as f64 * scale_factor;
// Is this safe? https://users.rust-lang.org/t/convert-an-f64-to-an-i32/50991/4
let min_width = min_width as u32;
let min_height = min_height as u32;
if resize_width < min_width {
resize_width = min_width;
resize_was_corrected = true;
}
if resize_height < min_height {
resize_height = min_height;
resize_was_corrected = true;
}
}
}
if resize_was_corrected {
println!("Resizing window to: {}x{} (corrected from {}x{})",
resize_width, resize_height, size.width, size.height);
} else {
println!("Resizing window to: {}x{} (no correction needed)", resize_width, resize_height);
}
window.set_size(PhysicalSize::new(resize_width, resize_height))?;
Ok(())
}
@@ -2,3 +2,11 @@
/// The name of the main application window.
/// This is the Artcraft application UX itself.
pub const MAIN_WINDOW_NAME : &str = "main";
// TODO: handle pixel density, eg. retina displays
/// This is the minimum width to resize the main window in an automated fashion
pub const MAIN_WINDOW_MIN_WIDTH : u32 = 800;
// TODO: handle pixel density, eg. retina displays
/// This is the minimum height to resize the main window in an automated fashion
pub const MAIN_WINDOW_MIN_HEIGHT : u32 = 600;
+18 -82
View File
@@ -12,6 +12,7 @@ use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::enqueue_image_to_video_command;
use crate::core::commands::flip_image::flip_image;
use crate::core::commands::get_app_info_command::get_app_info_command;
use crate::core::commands::load_without_cors_command::load_without_cors_command;
use crate::core::commands::platform_info_command::platform_info_command;
use crate::core::commands::providers::get_provider_order_command::get_provider_order_command;
use crate::core::commands::providers::set_provider_order_command::set_provider_order_command;
@@ -19,12 +20,9 @@ use crate::core::lifecycle::startup::handle_tauri_startup::handle_tauri_startup;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::main_window_size::MainWindowSize;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::threads::discord_presence_thread::discord_presence_thread;
use crate::core::threads::main_window_thread::main_window_thread::main_window_thread;
use crate::core::utils::webview_unsafe::webview_unsafe_for_app;
use crate::services::fal::commands::fal_background_removal_command::fal_background_removal_command;
use crate::services::fal::commands::fal_hunyuan_image_to_3d_command::fal_hunyuan_image_to_3d_command;
use crate::services::fal::commands::fal_kling_image_to_video_command::fal_kling_image_to_video_command;
@@ -87,36 +85,12 @@ pub fn run() {
let app_env_configs_2 = app_env_configs.clone();
let provider_priority = match ProviderPriorityStore::from_filesystem_configs(&app_data_root) {
Ok(Some(priority)) => {
println!("Loaded provider priority from disk: {:?}", priority.get_priority());
priority
}
Ok(None) => {
println!("No provider priority found on disk, using default.");
ProviderPriorityStore::default()
}
Err(err) => {
eprintln!("Failed to read provider priority from disk: {:?}", err);
ProviderPriorityStore::default()
}
};
println!("Initializing backend runtime...");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.targets(vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir {
file_name: Some(app_data_root.log_file_name_str().to_string())
}),
])
.build())
.setup(move |app| {
// TODO(bt): This is broken on windows
// log_environment_details();
@@ -133,66 +107,28 @@ pub fn run() {
let root = app_data_root_2.clone();
let env_config = app_env_configs_2.clone();
let storyteller_creds = storyteller_creds_manager_2.clone();
let sora_creds = sora_creds_manager_2.clone();
let sora_tasks = sora_task_queue_2.clone();
let fal_creds = fal_creds_manager_2.clone();
let fal_tasks = fal_task_queue_2.clone();
tauri::async_runtime::block_on(async move {
if let Err(err) = handle_tauri_startup(handle, root, env_config, storyteller_creds).await {
let result = handle_tauri_startup(
handle,
root,
env_config,
storyteller_creds,
sora_creds,
sora_tasks,
fal_creds,
fal_tasks,
).await;
if let Err(err) = result {
error!("Failed to handle Tauri startup: {:?}", err);
panic!("Failed to handle Tauri startup: {:?}", err);
}
});
let result = webview_unsafe_for_app(&app);
if let Err(err) = result {
eprintln!("Error setting webview unsafe: {:?}", err);
}
// TODO(bt): Clean this up. We can just clone at the callsite. Also clean initialization
let app_2 = app.clone();
let app_3 = app.clone();
let app_4 = app.clone();
let app_5 = app.clone();
let app_env_configs_3 = app_env_configs_2.clone();
let app_data_root_3 = app_data_root_2.clone();
let app_data_root_4 = app_data_root_2.clone();
let app_data_root_5 = app_data_root_2.clone();
let sora_creds_manager_3 = sora_creds_manager_2.clone();
let storyteller_creds_manager_3 = storyteller_creds_manager.clone();
let storyteller_creds_manager_4 = storyteller_creds_manager.clone();
let fal_creds_manager_3 = fal_creds_manager_2.clone();
let fal_task_queue_3 = fal_task_queue_2.clone();
match MainWindowSize::from_filesystem_configs(&app_data_root_3) {
Ok(None) => {}
Ok(Some(size)) => {
println!("Resizing window to: {:?}", size);
let result = size.apply_to_main_window(&app);
if let Err(err) = result {
eprintln!("Could not set window size: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window size from disk: {:?}", err);
}
}
match MainWindowPosition::from_filesystem_configs(&app_data_root_3) {
Ok(None) => {}
Ok(Some(pos)) => {
println!("Moving window to: {:?}", pos);
let result = pos.apply_to_main_window(&app);
if let Err(err) = result {
eprintln!("Could not set window position: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window position from disk: {:?}", err);
}
}
tauri::async_runtime::spawn(main_window_thread(app_3, app_data_root_3, storyteller_creds_manager_2));
tauri::async_runtime::spawn(sora_task_polling_thread(app_4, app_env_configs_2, app_data_root_4, sora_creds_manager_3, storyteller_creds_manager_3, sora_task_queue_2));
tauri::async_runtime::spawn(discord_presence_thread());
tauri::async_runtime::spawn(fal_task_polling_thread(app_5, app_env_configs_3, app_data_root_5, fal_creds_manager_3, fal_task_queue_3, storyteller_creds_manager_4));
Ok(())
})
@@ -201,7 +137,6 @@ pub fn run() {
.manage(app_preferences)
.manage(fal_creds_manager)
.manage(fal_task_queue)
.manage(provider_priority)
.manage(sora_creds_manager)
.manage(sora_task_queue)
.manage(storyteller_creds_manager_3)
@@ -220,6 +155,7 @@ pub fn run() {
get_app_preferences_command,
get_fal_api_key_command,
get_provider_order_command,
load_without_cors_command,
open_sora_login_command,
platform_info_command,
set_fal_api_key_command,
@@ -145,10 +145,12 @@ pub async fn sora_image_remix_command(
status = CommandErrorStatus::BadRequest;
error_message = "Your Sora username is not yet created. Please visit the Sora.com website to create it first.";
},
InnerError::SoraError(SoraError::BadGateway(_) | SoraError::CloudFlareTimeout(_)) => {
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
status = CommandErrorStatus::ServerError;
error_message = "Sora is having problems. Please try again later.";
InnerError::SoraError(err) => {
if err.is_sora_having_downtime_issues() {
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
status = CommandErrorStatus::ServerError;
error_message = "Sora is having problems. Please try again later.";
}
}
_ => {},
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "cloudflare_errors"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "cloudflare_errors"
path = "src/lib.rs"
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
# (None)
# External
# (None)
[dev-dependencies]
# (None)

Some files were not shown because too many files have changed in this diff Show More