Files
wp-agentic-writer/includes/class-image-manager.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

928 lines
28 KiB
PHP

<?php
/**
* Image Manager Class
*
* Handles image generation, variant management, and WordPress Media integration.
*
* @package WP_Agentic_Writer
* @since 0.1.0
*/
if (!defined("ABSPATH")) {
exit();
}
/**
* Image Manager class.
*/
class WP_Agentic_Writer_Image_Manager
{
/**
* Singleton instance.
*
* @var WP_Agentic_Writer_Image_Manager
*/
private static $instance = null;
/**
* Get singleton instance.
*
* @return WP_Agentic_Writer_Image_Manager
*/
public static function get_instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct()
{
// Private constructor for singleton.
}
/**
* Check if required tables exist.
*
* @since 0.1.0
* @return bool True if tables exist, false otherwise.
*/
public function tables_exist()
{
global $wpdb;
$table_images = $wpdb->prefix . "wpaw_images";
// Check if table exists using SHOW TABLES
$result = $wpdb->get_var("SHOW TABLES LIKE '{$table_images}'");
return $result === $table_images;
}
/**
* Ensure tables exist, create if missing.
*
* @since 0.1.0
* @return true|WP_Error True on success, WP_Error on failure.
*/
public function ensure_tables()
{
if (!$this->tables_exist()) {
$result = $this->create_tables();
if (!$result) {
return new WP_Error(
"table_creation_failed",
__(
"Failed to create image database tables. Please check database permissions.",
"wp-agentic-writer",
),
);
}
}
return true;
}
/**
* Create database tables on plugin activation.
*/
public function create_tables()
{
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
// Table 1: wp_wpaw_images
$table_images = $wpdb->prefix . "wpaw_images";
$sql_images = "CREATE TABLE IF NOT EXISTS `{$table_images}` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) NOT NULL,
`agent_image_id` varchar(50) NOT NULL,
`placement` varchar(100) DEFAULT NULL,
`section_title` varchar(255) DEFAULT NULL,
`prompt_initial` text NOT NULL,
`alt_text_initial` text DEFAULT NULL,
`prompt_edited` text DEFAULT NULL,
`alt_text_edited` text DEFAULT NULL,
`attachment_id` bigint(20) DEFAULT NULL,
`status` varchar(30) DEFAULT 'pending',
`cost_estimate` decimal(10, 4) DEFAULT NULL,
`cost_actual` decimal(10, 4) DEFAULT NULL,
`image_model` varchar(100) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_post` (`post_id`),
KEY `idx_agent_image_id` (`post_id`, `agent_image_id`),
KEY `idx_status` (`status`),
KEY `idx_created` (`created_at`)
) {$charset_collate};";
// Table 2: wp_wpaw_images_variants
$table_variants = $wpdb->prefix . "wpaw_images_variants";
$sql_variants = "CREATE TABLE IF NOT EXISTS `{$table_variants}` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`agentic_image_id` bigint(20) NOT NULL,
`post_id` bigint(20) NOT NULL,
`agent_image_id` varchar(50) NOT NULL,
`variant_number` int(11) DEFAULT 1,
`temp_file_path` varchar(500) NOT NULL,
`temp_file_url` varchar(500) NOT NULL,
`file_size` int(11) DEFAULT NULL,
`prompt_used` text DEFAULT NULL,
`image_model_used` varchar(100) DEFAULT NULL,
`generation_time` int(11) DEFAULT NULL,
`cost` decimal(10, 4) DEFAULT NULL,
`is_selected` tinyint(1) DEFAULT 0,
`selected_at` datetime DEFAULT NULL,
`status` varchar(30) DEFAULT 'temp',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_agentic_image` (`agentic_image_id`),
KEY `idx_post` (`post_id`),
KEY `idx_status` (`status`),
KEY `idx_created` (`created_at`)
) {$charset_collate};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql_images);
dbDelta($sql_variants);
// Create temp directory.
$this->create_temp_directory();
return true;
}
/**
* Create temp directory for image storage.
*/
private function create_temp_directory()
{
$upload_dir = wp_upload_dir();
$temp_dir = $upload_dir["basedir"] . "/wpaw";
if (!file_exists($temp_dir)) {
wp_mkdir_p($temp_dir);
// Add .htaccess to prevent direct access.
$htaccess = $temp_dir . "/.htaccess";
if (!file_exists($htaccess)) {
file_put_contents($htaccess, "Options -Indexes\n");
}
// Add index.php for security.
$index = $temp_dir . "/index.php";
if (!file_exists($index)) {
file_put_contents($index, "<?php // Silence is golden\n");
}
}
}
/**
* Analyze article for optimal image placement.
*
* @param string $article_markdown Article content in markdown.
* @param int $post_id Post ID.
* @return array|WP_Error Placement data or error.
*/
public function analyze_article_for_images($article_markdown, $post_id)
{
$settings = get_option("wp_agentic_writer_settings", []);
$writing_model =
$settings["writing_model"] ??
WPAW_Model_Registry::get_default_model("writing");
$system_prompt = "You are an expert content strategist analyzing articles for optimal image placement.
Your task: Identify 2-3 strategic locations where images would enhance understanding and engagement.
RULES:
1. Prioritize placement after introduction (hero image)
2. Consider complex sections that need visual aids
3. Look for opportunities before conclusions
4. Maximum 3 images per article
Return JSON:
{
\"recommended_image_count\": 3,
\"image_placement_points\": [
{
\"agent_image_id\": \"img_hero_1\",
\"placement\": \"after_introduction\",
\"section_title\": \"Introduction\",
\"image_type\": \"hero_dashboard\",
\"reasoning\": \"Sets visual tone for article\"
}
]
}";
$messages = [
[
"role" => "user",
"content" =>
"Analyze this article for image placement:\n\n" .
$article_markdown,
],
];
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
"planning",
);
$provider = $provider_result->provider;
$response = $provider->chat(
$messages,
["temperature" => 0.3],
"planning",
);
if (is_wp_error($response)) {
return $response;
}
// Extract JSON from response.
$json_match = [];
if (preg_match("/\{[\s\S]*\}/m", $response["content"], $json_match)) {
$placement_data = json_decode($json_match[0], true);
if (JSON_ERROR_NONE === json_last_error()) {
return $placement_data;
}
}
return new WP_Error(
"parse_error",
"Failed to parse placement analysis",
);
}
/**
* Generate image prompts optimized for specific image model.
*
* @param string $article_markdown Article content.
* @param array $placement_data Placement analysis.
* @param int $post_id Post ID.
* @return array|WP_Error Image specifications or error.
*/
public function generate_image_prompts(
$article_markdown,
$placement_data,
$post_id,
) {
$settings = get_option("wp_agentic_writer_settings", []);
$writing_model =
$settings["writing_model"] ??
WPAW_Model_Registry::get_default_model("writing");
$image_model =
$settings["image_model"] ??
WPAW_Model_Registry::get_default_model("image");
// Get model-specific prompt guidance.
$prompt_guidance = $this->get_prompt_guidance_for_model($image_model);
$system_prompt = "You are an Image Prompt Engineer specializing in {$prompt_guidance["model_name"]}.
TARGET MODEL: {$prompt_guidance["model_name"]}
PROMPT LENGTH: {$prompt_guidance["prompt_length"]}
COMPLEXITY: {$prompt_guidance["complexity"]}
{$prompt_guidance["guidance"]}
TEMPLATE: {$prompt_guidance["template"]}
Generate precise, cost-efficient prompts that exploit this model's strengths.
Return JSON:
{
\"images\": [
{
\"agent_image_id\": \"img_hero_1\",
\"placement\": \"after_introduction\",
\"section_title\": \"Introduction\",
\"prompt\": \"[Model-optimized prompt]\",
\"alt\": \"Descriptive alt text\",
\"image_model\": \"{$image_model}\"
}
]
}";
$user_input = wp_json_encode([
"article" => $article_markdown,
"placement_points" => $placement_data["image_placement_points"],
"image_count" => $placement_data["recommended_image_count"],
"target_image_model" => $image_model,
]);
$messages = [
[
"role" => "user",
"content" => "Generate image prompts:\n\n" . $user_input,
],
];
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
"planning",
);
$provider = $provider_result->provider;
$response = $provider->chat(
$messages,
["temperature" => 0.7],
"planning",
);
if (is_wp_error($response)) {
return $response;
}
// Extract JSON.
$json_match = [];
if (preg_match("/\{[\s\S]*\}/m", $response["content"], $json_match)) {
$image_specs = json_decode($json_match[0], true);
if (JSON_ERROR_NONE === json_last_error()) {
// Save to database.
$this->save_image_recommendations(
$post_id,
$image_specs["images"],
);
return $image_specs;
}
}
return new WP_Error("parse_error", "Failed to parse image prompts");
}
/**
* Get prompt guidance for specific image model.
*
* @param string $image_model Image model ID.
* @return array Model configuration.
*/
private function get_prompt_guidance_for_model($image_model)
{
$model_configs = [
"black-forest-labs/flux.2-klein" => [
"model_name" => "FLUX.2 [klein]",
"prompt_length" => "1-2 sentences",
"complexity" => "simple",
"guidance" =>
"Keep prompts short and simple. Focus on main subject, key details, and style. Avoid complex scenes or technical specifications.",
"template" => "Subject, key elements, style, color palette",
],
"sourceful/riverflow-v2-max" => [
"model_name" => "Riverflow V2 Max",
"prompt_length" => "3-4 sentences",
"complexity" => "medium-detailed",
"guidance" =>
"Include context, environment details, lighting style, and photographic specifications. Model excels at photorealism.",
"template" =>
"Subject + context, environment details, lighting style, photography style, technical specs",
],
"black-forest-labs/flux.2-max" => [
"model_name" => "FLUX.2 [max]",
"prompt_length" => "4-6 sentences",
"complexity" => "very-detailed-technical",
"guidance" =>
"Use detailed technical vocabulary. Include exact materials, color codes (HEX), spatial relationships, and specifications.",
"template" =>
"Technical foundation, main subject + action, environment, lighting + mood, style + aesthetics, technical specifications",
],
];
// Default to Riverflow if model not found.
return $model_configs[$image_model] ??
$model_configs["sourceful/riverflow-v2-max"];
}
/**
* Save image recommendations to database.
*
* @param int $post_id Post ID.
* @param array $images Image specifications.
*/
private function save_image_recommendations($post_id, $images)
{
// Ensure tables exist before saving
$check = $this->ensure_tables();
if (is_wp_error($check)) {
error_log(
"WPAW Image Manager: Cannot save recommendations - tables not available",
);
return;
}
global $wpdb;
$table = $wpdb->prefix . "wpaw_images";
foreach ($images as $image_spec) {
$wpdb->insert(
$table,
[
"post_id" => $post_id,
"agent_image_id" => $image_spec["agent_image_id"],
"placement" => $image_spec["placement"],
"section_title" => $image_spec["section_title"],
"prompt_initial" => $image_spec["prompt"],
"alt_text_initial" => $image_spec["alt"],
"image_model" => $image_spec["image_model"],
"status" => "pending",
],
["%d", "%s", "%s", "%s", "%s", "%s", "%s", "%s"],
);
}
}
/**
* Save single image recommendation to database.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Unique image identifier.
* @param string $placement Placement location.
* @param string $section_title Section title.
* @param string $prompt Image prompt/description.
* @param string $alt_text Alt text for image.
* @return int|false|WP_Error Insert ID, false on failure, or WP_Error if tables don't exist.
*/
public function save_image_recommendation(
$post_id,
$agent_image_id,
$placement,
$section_title,
$prompt,
$alt_text,
) {
// Ensure tables exist before saving
$check = $this->ensure_tables();
if (is_wp_error($check)) {
return $check;
}
global $wpdb;
$table = $wpdb->prefix . "wpaw_images";
$settings = get_option("wp_agentic_writer_settings", []);
$image_model =
$settings["image_model"] ??
WPAW_Model_Registry::get_default_model("image");
$result = $wpdb->insert(
$table,
[
"post_id" => $post_id,
"agent_image_id" => $agent_image_id,
"placement" => $placement,
"section_title" => $section_title,
"prompt_initial" => $prompt,
"alt_text_initial" => $alt_text,
"image_model" => $image_model,
"status" => "pending",
],
["%d", "%s", "%s", "%s", "%s", "%s", "%s", "%s"],
);
if ($result) {
return $wpdb->insert_id;
}
return false;
}
/**
* Get image recommendations for a post.
*
* @param int $post_id Post ID.
* @return array|WP_Error Image recommendations or error if tables don't exist.
*/
public function get_image_recommendations($post_id)
{
// Ensure tables exist before querying
$check = $this->ensure_tables();
if (is_wp_error($check)) {
return $check;
}
global $wpdb;
$table = $wpdb->prefix . "wpaw_images";
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at ASC",
$post_id,
),
ARRAY_A,
);
return $results;
}
/**
* Generate image variants.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param string $prompt Image prompt.
* @param int $variant_count Number of variants to generate.
* @return array|WP_Error Generated variants or error.
*/
public function generate_image_variants(
$post_id,
$agent_image_id,
$prompt,
$variant_count = 2,
) {
// Ensure tables exist before proceeding
$check = $this->ensure_tables();
if (is_wp_error($check)) {
return $check;
}
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
"image",
);
$provider = $provider_result->provider;
$variants = [];
for ($i = 1; $i <= $variant_count; $i++) {
// Let the provider resolve its own model based on its settings by passing null
$result = $provider->generate_image($prompt, null, [
"size" => "1024x576",
"quality" => "hd",
"n" => 1,
]);
if (is_wp_error($result)) {
return $result;
}
// Download image to temp directory.
$temp_file = $this->download_temp_image(
$post_id,
$agent_image_id,
$result["url"],
$i,
);
if (is_wp_error($temp_file)) {
return $temp_file;
}
// Save variant to database.
$variant_id = $this->save_variant(
$post_id,
$agent_image_id,
$i,
$temp_file,
$prompt,
$result["model"] ?? "unknown",
$result,
);
if (class_exists("WP_Agentic_Writer_Cost_Tracker")) {
WP_Agentic_Writer_Cost_Tracker::get_instance()->record_usage_full(
$post_id,
$result["model"] ?? "unknown",
"image_generation",
(int) ($result["input_tokens"] ?? 0),
(int) ($result["output_tokens"] ?? 0),
(float) ($result["cost"] ?? 0),
$provider_result->actual_provider ?? "openrouter",
"",
"success",
);
}
$variants[] = [
"id" => $variant_id,
"variant_number" => $i,
"temp_file_url" => $temp_file["url"],
"cost" => $result["cost"],
"generation_time" => $result["generation_time"],
"image_model_used" => $image_model,
];
}
return $variants;
}
/**
* Download image to temp directory.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param string $image_url Image URL.
* @param int $variant_number Variant number.
* @return array|WP_Error File info or error.
*/
private function download_temp_image(
$post_id,
$agent_image_id,
$image_url,
$variant_number,
) {
$upload_dir = wp_upload_dir();
$temp_dir = $upload_dir["basedir"] . "/wpaw/" . $post_id;
if (!file_exists($temp_dir)) {
wp_mkdir_p($temp_dir);
}
$image_data = "";
$extension = "jpg";
if (
preg_match(
'#^data:image/([a-zA-Z0-9.+-]+);base64,(.+)$#',
(string) $image_url,
$matches,
)
) {
$extension = strtolower($matches[1]);
$extension = "jpeg" === $extension ? "jpg" : $extension;
$image_data = base64_decode($matches[2]);
if (false === $image_data) {
return new WP_Error(
"invalid_image_data",
__(
"Generated image data could not be decoded.",
"wp-agentic-writer",
),
);
}
} else {
// Download image.
$response = wp_remote_get($image_url, ["timeout" => 30]);
if (is_wp_error($response)) {
return $response;
}
$image_data = wp_remote_retrieve_body($response);
// Determine file extension from content type.
$content_type = wp_remote_retrieve_header(
$response,
"content-type",
);
if (strpos($content_type, "png") !== false) {
$extension = "png";
} elseif (strpos($content_type, "webp") !== false) {
$extension = "webp";
}
}
$filename = sprintf(
"%s_variant_%d_%d.%s",
$agent_image_id,
$variant_number,
time(),
$extension,
);
$file_path = $temp_dir . "/" . $filename;
file_put_contents($file_path, $image_data);
$file_url =
$upload_dir["baseurl"] . "/wpaw/" . $post_id . "/" . $filename;
return [
"path" => $file_path,
"url" => $file_url,
"size" => filesize($file_path),
];
}
/**
* Save variant to database.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param int $variant_number Variant number.
* @param array $temp_file Temp file info.
* @param string $prompt Prompt used.
* @param string $image_model Image model used.
* @param array $generation_result Generation result.
* @return int Variant ID.
*/
private function save_variant(
$post_id,
$agent_image_id,
$variant_number,
$temp_file,
$prompt,
$image_model,
$generation_result,
) {
global $wpdb;
// Get agentic_image_id from wp_wpaw_images.
$table_images = $wpdb->prefix . "wpaw_images";
$agentic_image_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table_images} WHERE post_id = %d AND agent_image_id = %s",
$post_id,
$agent_image_id,
),
);
$table_variants = $wpdb->prefix . "wpaw_images_variants";
$wpdb->insert(
$table_variants,
[
"agentic_image_id" => $agentic_image_id,
"post_id" => $post_id,
"agent_image_id" => $agent_image_id,
"variant_number" => $variant_number,
"temp_file_path" => $temp_file["path"],
"temp_file_url" => $temp_file["url"],
"file_size" => $temp_file["size"],
"prompt_used" => $prompt,
"image_model_used" => $image_model,
"generation_time" => $generation_result["generation_time"],
"cost" => $generation_result["cost"],
"status" => "temp",
],
[
"%d",
"%d",
"%s",
"%d",
"%s",
"%s",
"%d",
"%s",
"%s",
"%d",
"%f",
"%s",
],
);
return $wpdb->insert_id;
}
/**
* Commit image variant to WordPress Media Library.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param int $variant_id Variant ID.
* @param string $alt_text Alt text.
* @return array|WP_Error Attachment info or error.
*/
public function commit_image_variant(
$post_id,
$agent_image_id,
$variant_id,
$alt_text,
) {
global $wpdb;
// Get variant info.
$table_variants = $wpdb->prefix . "wpaw_images_variants";
$variant = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table_variants} WHERE id = %d",
$variant_id,
),
ARRAY_A,
);
if (!$variant) {
return new WP_Error("variant_not_found", "Variant not found");
}
if (
empty($variant["temp_file_path"]) ||
!file_exists($variant["temp_file_path"])
) {
return new WP_Error(
"variant_file_missing",
__(
"Generated image file is missing. Please generate the variant again.",
"wp-agentic-writer",
),
);
}
// Upload to Media Library.
require_once ABSPATH . "wp-admin/includes/image.php";
require_once ABSPATH . "wp-admin/includes/file.php";
require_once ABSPATH . "wp-admin/includes/media.php";
$sideload_tmp = wp_tempnam(basename($variant["temp_file_path"]));
if (
!$sideload_tmp ||
!copy($variant["temp_file_path"], $sideload_tmp)
) {
return new WP_Error(
"variant_copy_failed",
__(
"Generated image could not be prepared for upload.",
"wp-agentic-writer",
),
);
}
$file_array = [
"name" => basename($variant["temp_file_path"]),
"tmp_name" => $sideload_tmp,
];
$attachment_id = media_handle_sideload($file_array, $post_id);
if (is_wp_error($attachment_id)) {
if (file_exists($sideload_tmp)) {
@unlink($sideload_tmp);
}
return $attachment_id;
}
// Set alt text.
update_post_meta(
$attachment_id,
"_wp_attachment_image_alt",
sanitize_text_field($alt_text),
);
// Update wp_wpaw_images table.
$table_images = $wpdb->prefix . "wpaw_images";
$wpdb->update(
$table_images,
[
"attachment_id" => $attachment_id,
"status" => "committed",
],
[
"post_id" => $post_id,
"agent_image_id" => $agent_image_id,
],
["%d", "%s"],
["%d", "%s"],
);
// Mark variant as selected.
$wpdb->update(
$table_variants,
[
"is_selected" => 1,
"selected_at" => current_time("mysql"),
"status" => "selected",
],
["id" => $variant_id],
["%d", "%s", "%s"],
["%d"],
);
$attachment_url = wp_get_attachment_url($attachment_id);
return [
"attachment_id" => $attachment_id,
"attachment_url" => $attachment_url,
"alt" => $alt_text,
];
}
/**
* Cleanup old temp images (7+ days old).
*/
public function cleanup_old_temp_images()
{
global $wpdb;
$table_variants = $wpdb->prefix . "wpaw_images_variants";
// Get temp images older than 7 days.
$old_variants = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table_variants}
WHERE status = 'temp'
AND created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
7,
),
ARRAY_A,
);
foreach ($old_variants as $variant) {
// Delete file.
if (file_exists($variant["temp_file_path"])) {
unlink($variant["temp_file_path"]);
}
// Update status.
$wpdb->update(
$table_variants,
[
"status" => "auto_deleted",
"deleted_at" => current_time("mysql"),
],
["id" => $variant["id"]],
["%s", "%s"],
["%d"],
);
}
}
}