From a2e93a625cd1e381e6106bcd342ac59df8509c91 Mon Sep 17 00:00:00 2001 From: Falko Zurell Date: Mon, 20 Jan 2025 09:47:00 +0100 Subject: [PATCH] added integration of OpenAI Image Description --- Cargo.toml | 3 +- README.md | 5 +++ config.json.example | 5 ++- src/main.rs | 92 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 26da7ae..8451a1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,5 @@ rustflags = ["--out-dir", "target/output"] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" reqwest = { version = "0.11", features = ["blocking", "json", "multipart"] } -tokio = { version = "1", features = ["full"] } +tokio = { version = "1.0", features = ["full"] } +base64 = "0.21" diff --git a/README.md b/README.md index 7af3833..451398e 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,8 @@ Usage: `./pixelfed_batch_uploader ../../Downloads/Instagram-Backup/media/posts/2 Check the [package of this repo](https://repos.mxhdr.net/maxheadroom/insta-import-pixelfed/packages) to get pre-compiled binaries for macOS (Apple Silicon), Linux x86_64, Windows ARM + + +## OpenAI Integration for Image Description + +Added OpenAI integration to generate image descriptions and put them into the ALT text for each image. If an `openai_api_key` is present in the `config.json` then the Image description is fetched from the OpenAI API. diff --git a/config.json.example b/config.json.example index 97a2d67..77da6c1 100644 --- a/config.json.example +++ b/config.json.example @@ -4,5 +4,8 @@ "access_token": "sdg;lkjrglksjh;lkshj;lksjthrst;hoijrt;ihj;sithj;itjh", "visibility": "unlisted", "batch_size": 10, - "default_text": "Instagram dump from @title@ @batch@ #instabackup #instaimport #instaexit" + "default_text": "Instagram dump from @title@ @batch@ #instabackup #instaimport #instaexit", + "openai_api_key": "0bff275feca7baab5ac508e635543f59fff42d4436c9918cd37c330f9adb4eb4fda643c212794b800bb05fb26016f55425c6755a3525c64792197e4d0fbe95d5", + "openai_api_url": "https://api.openai.com/v1/chat/completions", + "openai_model": "gpt-4o" } diff --git a/src/main.rs b/src/main.rs index 91feb4e..8c2c704 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ use std::fs; use serde::Deserialize; +use serde_json::{json, Value}; use reqwest::blocking::Client; +use reqwest::{self, multipart}; use std::error::Error; +use base64::{Engine as _, engine::general_purpose::STANDARD}; #[derive(Debug, Deserialize)] struct Config { @@ -10,6 +13,10 @@ struct Config { visibility: String, // Should be "unlisted" default_text: String, batch_size: usize, + openai_api_key: String, + openai_api_url: String, + openai_model: String + } fn load_config() -> Result> { @@ -33,19 +40,92 @@ fn get_jpeg_files(directory: &str) -> Vec { images } +async fn get_image_description(config: &Config, image_path: &String) -> Result> { + // Read and encode image + let image_data = tokio::fs::read(image_path) + .await + .map_err(|e| format!("Failed to read image file: {}", e))?; + + let base64_image = STANDARD.encode(&image_data); + + // Create ChatGPT API request + let client = reqwest::Client::new(); + let response = client + .post(&config.openai_api_url) + .header("Authorization", format!("Bearer {}", config.openai_api_key)) + .header("Content-Type", "application/json") + .json(&json!({ + "model": config.openai_model, + "max_tokens": 300, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Please describe this image concisely for use as an alt text description. Focus on key visual elements and context." + }, + { + "type": "image_url", + "image_url": { + "url": format!("data:image/jpeg;base64,{}", base64_image) + } + } + ] + } + ] + })) + .send() + .await?; + + // Improved error handling for API response + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("OpenAI API error: {}", error_text).into()); + } + + let result: Value = response.json().await?; + + // More detailed error handling for JSON parsing + let description = result["choices"] + .get(0) + .ok_or("No choices in response")? + ["message"]["content"] + .as_str() + .ok_or("Invalid content format in response")? + .to_string(); + + Ok(description) +} + + fn format_post_text(template: &str, batch_num: usize, total_batches: usize, title: &str) -> String { template .replace("@batch@", &format!("Batch {} out of {}", batch_num, total_batches)) .replace("@title@", title) } -fn upload_images_batch(client: &Client, config: &Config, images: &[String], batch_num: usize, total_batches: usize, title: &str) -> Result<(), Box> { +async fn upload_images_batch(client: &Client, config: &Config, images: &[String], batch_num: usize, total_batches: usize, title: &str) -> Result<(), Box> { let url = format!("{}/api/v1/media", config.pixelfed_url); let mut media_ids = Vec::new(); + let mut media_descriptions = Vec::new(); for image_path in images { + println!("Fetching image description from OpenAI for {}", image_path.to_string()); + let image_description: String ; + if !config.openai_api_key.is_empty() { + image_description = get_image_description(&config, &image_path).await?; + println!("DESC:\n {} \n", &image_description); + media_descriptions.push(image_description.clone()); + + } else { + image_description = String::new(); + } + + println!("Uploading image {}", image_path.to_string()); - let form = reqwest::blocking::multipart::Form::new() + + let form = reqwest::blocking::multipart::Form::new().text("description", image_description.clone()) .file("file", image_path)?; let res = client.post(&url) @@ -65,10 +145,11 @@ fn upload_images_batch(client: &Client, config: &Config, images: &[String], batc let body = serde_json::json!({ "status": post_text, "media_ids": media_ids, + "alt_texts": media_descriptions, "visibility": config.visibility, }); - println!("Posting batch {} out of {}", batch_num, total_batches); + println!("Posting batch {} out of {} with media {}", batch_num, total_batches, media_ids.len()); client.post(&post_url) .bearer_auth(&config.access_token) .json(&body) @@ -78,7 +159,8 @@ fn upload_images_batch(client: &Client, config: &Config, images: &[String], batc Ok(()) } -fn main() -> Result<(), Box> { +#[tokio::main] +async fn main() -> Result<(), Box> { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!("Usage: {} [--title ]", args[0]); @@ -98,7 +180,7 @@ fn main() -> Result<(), Box<dyn Error>> { let total_batches = (images.len() + config.batch_size - 1) / config.batch_size; println!("Found a total of {} images to upload. Will take {} batches", images.len(), total_batches); for (i, chunk) in images.chunks(config.batch_size).enumerate() { - upload_images_batch(&client, &config, chunk, i + 1, total_batches, &title)?; + upload_images_batch(&client, &config, chunk, i + 1, total_batches, &title).await?; } println!("All images uploaded successfully.");