return $result; } private function is_instagrapi_stories_enabled() { if (!get_option('instagram_instagrapi_enabled', false)) { return false; } if (file_exists($this->get_instagrapi_session_file())) { return true; } if (get_option('instagram_instagrapi_sessionid', '') !== '') { return true; } $username = get_option('instagram_instagrapi_username', ''); $password = get_option('instagram_instagrapi_password', ''); return $username !== '' && $password !== ''; } private function build_instagrapi_payload($extra = array()) { return array_merge( array( 'username' => get_option('instagram_instagrapi_username', ''), 'password' => get_option('instagram_instagrapi_password', ''), 'sessionid' => get_option('instagram_instagrapi_sessionid', ''), 'proxy' => get_option('instagram_instagrapi_proxy', ''), 'session_file' => $this->get_instagrapi_session_file(), 'share_to_facebook' => (bool) get_option('instagram_instagrapi_share_facebook', true), 'facebook_page_id' => get_option('facebook_page_id', ''), 'facebook_destination_type' => 'PAGE', ), $extra ); } private function run_instagrapi_script($script_name, array $payload) { $script = ABSPATH . 'scripts/instagram/' . $script_name; if (!is_readable($script)) { return new WP_Error('missing_script', 'No se encontró el script de instagrapi.'); } $python = $this->get_instagrapi_python_binary(); $json_payload = wp_json_encode($payload); if (!$json_payload) { return new WP_Error('payload', 'No se pudo preparar la solicitud para instagrapi.'); } $descriptors = array( 0 => array('pipe', 'r'), 1 => array('pipe', 'w'), 2 => array('pipe', 'w'), ); $process = proc_open( array($python, $script), $descriptors, $pipes, ABSPATH . 'scripts/instagram' ); if (!is_resource($process)) { return new WP_Error('process', 'No se pudo ejecutar instagrapi.'); } fwrite($pipes[0], $json_payload); fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exit_code = proc_close($process); $decoded = $this->decode_instagrapi_stdout((string) $stdout); if ($exit_code !== 0 || !is_array($decoded)) { $message = is_array($decoded) && !empty($decoded['error']) ? $decoded['error'] : trim($stderr !== '' ? $stderr : $stdout); if ($message === '') { $message = 'Error desconocido al ejecutar instagrapi.'; } return new WP_Error('instagrapi', $message); } if (empty($decoded['success'])) { return new WP_Error('instagrapi', $decoded['error'] ?? 'No se pudo completar la operación con instagrapi.'); } return $decoded; } private function decode_instagrapi_stdout($stdout) { $stdout = trim($stdout); if ($stdout === '') { return null; } $decoded = json_decode($stdout, true); if (is_array($decoded)) { return $decoded; } $json_start = strrpos($stdout, '{'); $json_end = strrpos($stdout, '}'); if ($json_start === false || $json_end === false || $json_end <= $json_start) { return null; } $decoded = json_decode(substr($stdout, $json_start, $json_end - $json_start + 1), true); return is_array($decoded) ? $decoded : null; } private function get_instagrapi_python_binary() { $venv_python = ABSPATH . 'scripts/instagram/.venv/bin/python3'; if (is_executable($venv_python)) { return $venv_python; } return '/usr/bin/python3'; } private function get_instagrapi_session_file() { $upload_dir = wp_upload_dir(); $private_dir = $upload_dir['basedir'] . '/vln-private'; if (!is_dir($private_dir)) { wp_mkdir_p($private_dir); file_put_contents($private_dir . '/.htaccess', "Deny from all\n"); file_put_contents($private_dir . '/index.php', " 120, 'redirection' => 5, 'sslverify' => true, 'headers' => array( 'User-Agent' => 'VLN-Instagram-Story-Upload/1.0', ), ) ); if (is_wp_error($response)) { return new WP_Error( 'image_download', 'No se pudo descargar la imagen de la story: ' . $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code($response); if ($code !== 200) { return new WP_Error( 'image_download', 'No se pudo descargar la imagen de la story (HTTP ' . $code . ').' ); } $body = wp_remote_retrieve_body($response); if ($body === '') { return new WP_Error('image_download', 'La imagen descargada está vacía.'); } $path = wp_tempnam('vln-story-'); if (!$path) { return new WP_Error('image_download', 'No se pudo crear un archivo temporal para la story.'); } if (false === file_put_contents($path, $body)) { @unlink($path); return new WP_Error('image_download', 'No se pudo guardar la imagen temporal de la story.'); } return $path; } private function upload_story_via_instagrapi($image_url, $link_url, $link_coords = array()) { if (!$this->is_instagrapi_stories_enabled()) { return new WP_Error('config', 'Instagram Stories con instagrapi no está configurado.'); } $image_path = $this->prepare_instagrapi_story_image($image_url); if (is_wp_error($image_path)) { return $image_path; } $payload = array( 'image_path' => $image_path, 'link_url' => $link_url, ); foreach (array('x', 'y', 'width', 'height') as $key) { if (isset($link_coords[$key]) && is_numeric($link_coords[$key])) { $payload['link_' . $key] = (float) $link_coords[$key]; } } $result = $this->run_instagrapi_script( 'instagram_story_upload.py', $this->build_instagrapi_payload($payload) ); @unlink($image_path); if (is_wp_error($result)) { return $result; } return array( 'success' => true, 'id' => $result['story_id'] ?? ($result['story_pk'] ?? ''), 'story_code' => $result['story_code'] ?? '', 'provider' => 'instagrapi', 'has_functional_link' => !empty($result['has_functional_link']), 'link_url' => $link_url, 'shared_to_facebook' => !empty($result['shared_to_facebook']), 'facebook_destination_id' => $result['facebook_destination_id'] ?? '', ); } public function ajax_test_instagrapi_connection() { if (!current_user_can('manage_options') && !$this->current_user_can_access_lab()) { wp_send_json_error('Sin permiso.', 403); } if (!wp_verify_nonce($_POST['nonce'] ?? '', 'instagram_test_nonce')) { wp_send_json_error('Error de seguridad'); } if (!$this->is_instagrapi_stories_enabled()) { wp_send_json_error('Configura instagrapi antes de probar la conexión.'); } $result = $this->run_instagrapi_script( 'instagram_test_connection.py', $this->build_instagrapi_payload() ); if (is_wp_error($result)) { wp_send_json_error($result->get_error_message()); } wp_send_json_success($result); } public function ajax_upload_instagram_story_instagrapi() { if (!$this->current_user_can_access_lab()) { wp_send_json_error('Sin permiso para usar instagrapi en el laboratorio.', 403); } if (!wp_verify_nonce($_POST['nonce'] ?? '', 'vln_instagram_story_instagrapi')) { wp_send_json_error('Error de seguridad'); } $image_url = esc_url_raw(trim((string) ($_POST['imgurl'] ?? ''))); $link_url = esc_url_raw(trim((string) ($_POST['linkurl'] ?? ''))); $link_coords = array(); foreach (array('x', 'y', 'width', 'height') as $key) { $param = 'link_' . $key; if (isset($_POST[$param]) && is_numeric($_POST[$param])) { $link_coords[$key] = (float) $_POST[$param]; } } if ($image_url === '' || $link_url === '') { wp_send_json_error('Faltan la imagen o el enlace de la story.'); } $result = $this->upload_story_via_instagrapi($image_url, $link_url, $link_coords); if (is_wp_error($result)) { wp_send_json_error($result->get_error_message()); } wp_send_json_success($result); } private function get_reels_work_dir() { $upload_dir = wp_upload_dir(); $dir = $upload_dir['basedir'] . '/vln-reels'; if (!is_dir($dir)) { wp_mkdir_p($dir); file_put_contents($dir . '/.htaccess', "Options -Indexes\n"); file_put_contents($dir . '/index.php', "measure_text_width($text, $font_path, $font_size) <= $max_width_px) { return $text; } $suffix_width = $this->measure_text_width($suffix, $font_path, $font_size); $available = max(1, $max_width_px - $suffix_width); $chars = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); $result = ''; foreach ($chars as $char) { $candidate = $result . $char; if ($this->measure_text_width($candidate, $font_path, $font_size) > $available) { break; } $result = $candidate; } return rtrim($result) . $suffix; } private function wrap_reel_text_lines($text, $font_path, $font_size, $max_width_px, $max_lines = 4) { $text = trim(preg_replace('/\s+/u', ' ', $text)); if ($text === '') { return array(); } $lines = array(); $words = preg_split('/\s+/u', $text); $current = ''; foreach ($words as $word) { while ($this->measure_text_width($word, $font_path, $font_size) > $max_width_px) { if ($current !== '') { $lines[] = $current; $current = ''; if (count($lines) >= $max_lines) { $lines[$max_lines - 1] = $this->truncate_text_to_width($lines[$max_lines - 1], $font_path, $font_size, $max_width_px); return $lines; } } $chunk = ''; $chars = preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY); foreach ($chars as $char) { $candidate = $chunk . $char; if ($chunk !== '' && $this->measure_text_width($candidate, $font_path, $font_size) > $max_width_px) { $lines[] = $chunk; $chunk = $char; if (count($lines) >= $max_lines) { $lines[$max_lines - 1] = $this->truncate_text_to_width($lines[$max_lines - 1], $font_path, $font_size, $max_width_px); return $lines; } } else { $chunk = $candidate; } } $word = $chunk; break; } $candidate = $current === '' ? $word : $current . ' ' . $word; if ($this->measure_text_width($candidate, $font_path, $font_size) <= $max_width_px) { $current = $candidate; continue; } if ($current !== '') { $lines[] = $current; if (count($lines) >= $max_lines) { $lines[$max_lines - 1] = $this->truncate_text_to_width($lines[$max_lines - 1], $font_path, $font_size, $max_width_px); return $lines; } } $current = $word; } if ($current !== '' && count($lines) < $max_lines) { $lines[] = $current; } if (count($lines) > $max_lines) { $lines = array_slice($lines, 0, $max_lines); $lines[$max_lines - 1] = $this->truncate_text_to_width($lines[$max_lines - 1], $font_path, $font_size, $max_width_px); } return $lines; } private function wrap_reel_title_lines($title, $max_chars = 32, $max_lines = 4) { $words = preg_split('/\s+/u', trim($title)); $lines = array(); $current = ''; foreach ($words as $word) { $candidate = $current === '' ? $word : $current . ' ' . $word; if (mb_strlen($candidate) <= $max_chars) { $current = $candidate; continue; } if ($current !== '') { $lines[] = $current; } $current = $word; if (count($lines) >= $max_lines) { break; } } if ($current !== '' && count($lines) < $max_lines) { $lines[] = $current; } if (count($lines) >= $max_lines && count($words) > count($lines)) { $last = array_pop($lines); if ($last !== null && mb_strlen($last) > 3) { $lines[] = rtrim(mb_substr($last, 0, mb_strlen($last) - 3)) . '...'; } else { $lines[] = $last; } } return $lines; } private function download_reels_logo_png() { $cache_dir = $this->get_reels_work_dir(); $cache_file = $cache_dir . '/vln-whitelogo.png'; if (is_readable($cache_file) && filesize($cache_file) > 500) { return $cache_file; } $local_logo = ABSPATH . 'estaticos/whitelogo.png'; if (is_readable($local_logo) && filesize($local_logo) > 500) { if (@copy($local_logo, $cache_file)) { return $cache_file; } } $response = wp_remote_get( self::REELS_LOGO_URL, array( 'timeout' => 30, 'headers' => array('User-Agent' => 'VLN-Reels-Maker/1.0'), ) ); if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 200) { return new WP_Error('logo', 'No se pudo descargar el logo de VLN Radio.'); } $body = wp_remote_retrieve_body($response); if ($body === '') { return new WP_Error('logo', 'El logo descargado está vacío.'); } if (false === file_put_contents($cache_file, $body)) { return new WP_Error('logo', 'No se pudo guardar el logo temporal.'); } return $cache_file; } private function imagecopy_alpha($dst, $src, $dst_x, $dst_y, $src_x, $src_y, $src_w, $src_h) { imagealphablending($dst, true); imagesavealpha($dst, true); imagecopy($dst, $src, $dst_x, $dst_y, $src_x, $src_y, $src_w, $src_h); } private function prepare_logo_with_transparency($logo_path) { $logo = imagecreatefrompng($logo_path); if (!$logo) { return new WP_Error('logo', 'No se pudo cargar el logo.'); } imagesavealpha($logo, true); imagealphablending($logo, false); $width = imagesx($logo); $height = imagesy($logo); for ($x = 0; $x < $width; $x++) { for ($y = 0; $y < $height; $y++) { $rgba = imagecolorat($logo, $x, $y); $r = ($rgba >> 16) & 0xFF; $g = ($rgba >> 8) & 0xFF; $b = $rgba & 0xFF; if ($r < 40 && $g < 40 && $b < 40) { imagesetpixel($logo, $x, $y, imagecolorallocatealpha($logo, 0, 0, 0, 127)); } } } $path = wp_tempnam('vln-reel-logo-alpha-'); if (!$path) { imagedestroy($logo); return new WP_Error('logo', 'No se pudo preparar el logo transparente.'); } imagepng($logo, $path); imagedestroy($logo); return $path; } private function get_reel_timing($title_duration) { $duration = max(2, min(8, (int) $title_duration)); if ($duration <= 0) { $duration = self::REEL_TITLE_DEFAULT_DURATION; } $fade_out = self::REEL_TITLE_FADE_OUT; $visible_until = max(0.5, $duration - $fade_out); return array( 'title_duration' => $duration, 'title_fade_in' => self::REEL_TITLE_FADE_IN, 'title_visible_until' => $visible_until, 'title_fade_out' => $fade_out, 'logo_start' => $duration, 'logo_fade_in' => self::REEL_LOGO_FADE_IN, ); } private function get_reel_meta_path($filename) { return $this->get_reels_work_dir() . '/meta-' . preg_replace('/\.mp4$/', '', $filename) . '.json'; } private function write_reel_meta($filename, array $meta) { if (!$this->is_valid_reel_filename($filename)) { return; } file_put_contents($this->get_reel_meta_path($filename), wp_json_encode($meta)); } private function read_reel_meta($filename) { $path = $this->get_reel_meta_path($filename); if (!is_readable($path)) { return array(); } $decoded = json_decode((string) file_get_contents($path), true); return is_array($decoded) ? $decoded : array(); } private function list_recent_reels($limit = 12) { $dir = $this->get_reels_work_dir(); $files = glob($dir . '/reel-vln-*.mp4') ?: array(); usort($files, function ($a, $b) { return filemtime($b) <=> filemtime($a); }); $items = array(); foreach (array_slice($files, 0, $limit) as $path) { $filename = basename($path); if (!$this->is_valid_reel_filename($filename)) { continue; } $meta = $this->read_reel_meta($filename); $mtime = filemtime($path) ?: time(); $items[] = array( 'filename' => $filename, 'url' => $this->get_reel_download_url($filename, true), 'download_url' => $this->get_reel_download_url($filename, false), 'size_mb' => round(filesize($path) / 1048576, 1), 'created' => $mtime, 'display_date' => wp_date('d/m/Y H:i', $mtime), 'headline' => isset($meta['headline']) ? (string) $meta['headline'] : '', 'title' => isset($meta['title']) ? (string) $meta['title'] : '', ); } return $items; } public function ajax_list_reels() { $this->verify_reel_ajax_request(); wp_send_json_success(array( 'items' => $this->list_recent_reels(), )); } private function get_headline_block_height($line_count, $font_size, $line_height) { if ($line_count <= 0) { return 0; } if ($line_count === 1) { return $font_size + 4; } return $font_size + (($line_count - 1) * $line_height); } private function draw_reel_separator_row($image, $row_y, $row_height, $logo_resized, $logo_w, $logo_h, $margin_x, $canvas_width, $line_color, $draw_lines = true) { $logo_x = $canvas_width - $margin_x - $logo_w; $logo_y = $row_y + (int) round(($row_height - $logo_h) / 2); $line_gap = 18; $line_y = $row_y + (int) round($row_height / 2); if ($draw_lines) { $line_end = $logo_x - $line_gap; if ($line_end > $margin_x) { imageline($image, $margin_x, $line_y, $line_end, $line_y, $line_color); imageline($image, $margin_x, $line_y + 1, $line_end, $line_y + 1, $line_color); } } $this->imagecopy_alpha($image, $logo_resized, $logo_x, $logo_y, 0, 0, $logo_w, $logo_h); } private function generate_reel_title_overlay($headline, $body, $logo_path) { $width = 1080; $height = 1920; $image = imagecreatetruecolor($width, $height); imagesavealpha($image, true); imagealphablending($image, false); $transparent = imagecolorallocatealpha($image, 0, 0, 0, 127); imagefill($image, 0, 0, $transparent); imagealphablending($image, true); $gradient_start = (int) round($height * 0.52); for ($y = $gradient_start; $y < $height; $y++) { $progress = ($y - $gradient_start) / max(1, $height - $gradient_start - 1); $alpha = (int) round(min(110, $progress * 110)); $color = imagecolorallocatealpha($image, 0, 0, 0, max(0, min(127, 127 - $alpha))); imageline($image, 0, $y, $width, $y, $color); } $margin_x = 54; $margin_bottom = self::REEL_SAFE_BOTTOM; $max_text_width = $width - ($margin_x * 2); $logo_target_w = self::REEL_INTRO_LOGO_WIDTH; $font_bold = $this->get_reels_font_path('bold'); $font_regular = $this->get_reels_font_path('regular'); $white = imagecolorallocate($image, 255, 255, 255); $logo = imagecreatefrompng($logo_path); if (!$logo) { imagedestroy($image); return new WP_Error('overlay', 'No se pudo cargar el logo.'); } $logo_src_w = imagesx($logo); $logo_src_h = imagesy($logo); $logo_target_h = (int) round($logo_src_h * ($logo_target_w / max(1, $logo_src_w))); $has_headline = trim($headline) !== ''; $headline_font_size = self::REEL_HEADLINE_FONT_SIZE; $headline_line_height = $headline_font_size + 8; $headline_font = $font_bold !== '' ? $font_bold : $font_regular; $headline_lines = $has_headline ? $this->wrap_reel_text_lines(trim($headline), $headline_font, $headline_font_size, $max_text_width, 3) : array(); $body_font_size = self::REEL_BODY_FONT_SIZE; $body_font = $font_regular !== '' ? $font_regular : $font_bold; $body_lines = $this->wrap_reel_text_lines($body, $body_font, $body_font_size, $max_text_width, 4); if (count($body_lines) > 3) { $body_font_size = self::REEL_BODY_FONT_SIZE_COMPACT; $body_lines = $this->wrap_reel_text_lines($body, $body_font, $body_font_size, $max_text_width, 4); } $body_line_height = $body_font_size + 10; $separator_after_gap = (count($body_lines) === 1) ? self::REEL_SEPARATOR_GAP_TIGHT : self::REEL_SEPARATOR_GAP; $headline_line_count = count($headline_lines); $headline_text_height = $has_headline ? $this->get_headline_block_height($headline_line_count, $headline_font_size, $headline_line_height) : 0; $separator_gap = ($has_headline && $headline_line_count === 1) ? self::REEL_SEPARATOR_GAP_TIGHT : self::REEL_SEPARATOR_GAP; $separator_row_height = 18; $logo_only_gap = 18; if ($has_headline) { $intro_block = $headline_text_height + $separator_gap + $separator_row_height + $separator_after_gap; } else { $intro_block = $separator_row_height + $logo_only_gap; } $body_block = count($body_lines) * $body_line_height; $content_height = $intro_block + $body_block; $cursor_y = $height - $margin_bottom - $content_height; $separator_row_y = $cursor_y; $logo_resized = imagecreatetruecolor($logo_target_w, $logo_target_h); imagesavealpha($logo_resized, true); imagealphablending($logo_resized, false); $logo_transparent = imagecolorallocatealpha($logo_resized, 0, 0, 0, 127); imagefill($logo_resized, 0, 0, $logo_transparent); imagecopyresampled($logo_resized, $logo, 0, 0, 0, 0, $logo_target_w, $logo_target_h, $logo_src_w, $logo_src_h); if ($has_headline) { foreach ($headline_lines as $index => $line) { $y = $cursor_y + $headline_font_size + ($index * $headline_line_height); if ($font_bold !== '') { imagettftext($image, $headline_font_size, 0, $margin_x, $y, $white, $font_bold, $line); } else { imagestring($image, 5, $margin_x, $y - $headline_font_size, $line, $white); } } $cursor_y += $headline_text_height + $separator_gap; $separator_row_y = $cursor_y; $this->draw_reel_separator_line($image, $cursor_y, $separator_row_height, $margin_x, $line_end_x, $white); $cursor_y += $separator_row_height + $separator_after_gap; } else { $separator_row_y = $cursor_y; $this->draw_reel_separator_line($image, $cursor_y, $separator_row_height, $margin_x, $line_end_x, $white); $cursor_y += $separator_row_height + $logo_only_gap; } foreach ($body_lines as $index => $line) { $y = $cursor_y + $body_font_size + ($index * $body_line_height); if ($font_regular !== '') { imagettftext($image, $body_font_size, 0, $margin_x, $y, $white, $font_regular, $line); } elseif ($font_bold !== '') { imagettftext($image, $body_font_size, 0, $margin_x, $y, $white, $font_bold, $line); } else { imagestring($image, 5, $margin_x, $y - 12, $line, $white); } } $logo_y = $separator_row_y + (int) round(($separator_row_height - $logo_target_h) / 2); $this->imagecopy_alpha($image, $logo_resized, $logo_x, $logo_y, 0, 0, $logo_target_w, $logo_target_h); imagedestroy($logo); imagedestroy($logo_resized); $path = wp_tempnam('vln-reel-title-'); if (!$path) { imagedestroy($image); return new WP_Error('overlay', 'No se pudo crear el archivo temporal del titular.'); } imagepng($image, $path); imagedestroy($image); return array( 'path' => $path, 'overlay_y' => $strip_y, ); } private function get_reel_audio_encoding_args($video_path) { if (!is_readable($video_path)) { return '-c:a aac -b:a 128k'; } $cmd = 'ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 ' . escapeshellarg($video_path) . ' 2>/dev/null'; $codec = strtolower(trim((string) shell_exec($cmd))); if ($codec === '') { return ''; } if (in_array($codec, array('aac', 'mp4a'), true)) { return '-c:a copy'; } return '-c:a aac -b:a 128k'; } private function get_video_source_duration($video_path) { if (!is_readable($video_path)) { return 0.0; } $cmd = 'ffprobe -v error -show_entries format=duration -of csv=p=0 ' . escapeshellarg($video_path) . ' 2>/dev/null'; $duration = (float) trim((string) shell_exec($cmd)); return $duration > 0 ? $duration : 0.0; } private function normalize_video_repetitions($repetitions) { return max(1, min(self::REEL_MAX_REPETITIONS, (int) $repetitions)); } private function get_reel_output_duration($source_duration, $repetitions, $clip_start = 0, $clip_end = 0) { $repetitions = $this->normalize_video_repetitions($repetitions); $clip_duration = $this->get_reel_clip_duration($source_duration, $clip_start, $clip_end); if ($clip_duration <= 0) { return 0.0; } return min($clip_duration * $repetitions, (float) self::REEL_MAX_DURATION); } private function get_video_duration($video_path) { return $this->get_reel_output_duration($this->get_video_source_duration($video_path), 1); } private function crop_logo_bounds($logo) { $width = imagesx($logo); $height = imagesy($logo); $min_x = $width; $min_y = $height; $max_x = 0; $max_y = 0; for ($x = 0; $x < $width; $x++) { for ($y = 0; $y < $height; $y++) { $rgba = imagecolorat($logo, $x, $y); $alpha = ($rgba & 0x7F000000) >> 24; $r = ($rgba >> 16) & 0xFF; $g = ($rgba >> 8) & 0xFF; $b = $rgba & 0xFF; if ($alpha >= 120) { continue; } if ($r < 40 && $g < 40 && $b < 40) { continue; } $min_x = min($min_x, $x); $min_y = min($min_y, $y); $max_x = max($max_x, $x); $max_y = max($max_y, $y); } } if ($max_x <= $min_x || $max_y <= $min_y) { return null; } return array( 'x' => $min_x, 'y' => $min_y, 'w' => $max_x - $min_x + 1, 'h' => $max_y - $min_y + 1, ); } private function prepare_reel_logo_png($logo_path, $dest_path, $target_width) { $logo = imagecreatefrompng($logo_path); if (!$logo) { return new WP_Error('overlay', 'No se pudo cargar el logo.'); } imagesavealpha($logo, true); imagealphablending($logo, false); $bounds = $this->crop_logo_bounds($logo); if ($bounds === null) { imagedestroy($logo); return new WP_Error('overlay', 'No se detectó contenido visible en el logo.'); } $cropped = imagecreatetruecolor($bounds['w'], $bounds['h']); imagesavealpha($cropped, true); imagealphablending($cropped, false); $transparent = imagecolorallocatealpha($cropped, 0, 0, 0, 127); imagefill($cropped, 0, 0, $transparent); imagecopy($cropped, $logo, 0, 0, $bounds['x'], $bounds['y'], $bounds['w'], $bounds['h']); imagedestroy($logo); $target_w = max(1, (int) $target_width); $target_h = max(1, (int) round($bounds['h'] * ($target_w / max(1, $bounds['w'])))); $resized = imagecreatetruecolor($target_w, $target_h); imagesavealpha($resized, true); imagealphablending($resized, false); imagefill($resized, 0, 0, $transparent); imagealphablending($resized, true); imagecopyresampled($resized, $cropped, 0, 0, 0, 0, $target_w, $target_h, $bounds['w'], $bounds['h']); imagedestroy($cropped); if (!imagepng($resized, $dest_path)) { imagedestroy($resized); return new WP_Error('overlay', 'No se pudo guardar el logo procesado.'); } imagedestroy($resized); return $dest_path; } private function prepare_reel_logo_watermark($logo_path, $dest_path) { return $this->prepare_reel_logo_png($logo_path, $dest_path, self::REEL_LOGO_WIDTH); } private function is_valid_reel_filename($filename) { return is_string($filename) && preg_match('/^reel-vln-[0-9]{8}-[0-9]{6}-[a-zA-Z0-9]+\.mp4$/', $filename); } private function get_reel_download_token($filename, $user_id = null) { $user_id = $user_id ?? get_current_user_id(); return hash_hmac('sha256', $filename . '|' . (int) $user_id, wp_salt('auth')); } private function verify_reel_download_token($filename, $token, $user_id) { if (!$this->current_user_can_access_reels()) { return false; } if ((int) get_current_user_id() !== (int) $user_id) { return false; } if (!$this->is_valid_reel_filename($filename)) { return false; } $expected = $this->get_reel_download_token($filename, $user_id); return is_string($token) && hash_equals($expected, $token); } private function get_reel_download_url($filename, $inline = false) { $user_id = get_current_user_id(); $args = array( 'action' => 'vln_download_reel', 'file' => $filename, 'uid' => $user_id, 'token' => $this->get_reel_download_token($filename, $user_id), ); if ($inline) { $args['inline'] = '1'; } return add_query_arg($args, admin_url('admin-ajax.php')); } private function enrich_reel_status_urls(array $status) { if (!empty($status['filename'])) { $status['url'] = $this->get_reel_download_url($status['filename'], true); $status['download_url'] = $this->get_reel_download_url($status['filename'], false); } if (($status['status'] ?? '') === 'processing' && !empty($status['job_id'])) { $progress = $this->read_reel_job_progress( $status['job_id'], (float) ($status['duration'] ?? 0) ); if ($progress !== null) { $status['progress'] = $progress['percent']; $status['progress_label'] = $progress['label']; } } return $status; } private function normalize_reel_quality($quality) { return $quality === 'draft' ? 'draft' : 'final'; } private function get_reel_encode_profile($quality) { if ($this->normalize_reel_quality($quality) === 'draft') { return array( 'width' => self::REEL_DRAFT_WIDTH, 'height' => self::REEL_DRAFT_HEIGHT, 'preset' => self::REEL_DRAFT_PRESET, 'crf' => self::REEL_DRAFT_CRF, 'logo_margin' => 32, 'logo_width' => 133, ); } return array( 'width' => 1080, 'height' => 1920, 'preset' => self::REEL_FFMPEG_PRESET, 'crf' => self::REEL_FFMPEG_CRF, 'logo_margin' => 48, 'logo_width' => self::REEL_LOGO_WIDTH, ); } private function get_reel_source_meta_path($token) { return $this->get_reels_work_dir() . '/source-' . $token . '.json'; } private function get_reel_source_video_path($user_id, $token, $ext) { return $this->get_reels_work_dir() . '/source-' . (int) $user_id . '-' . $token . '.' . $ext; } private function read_reel_source_meta($token) { $path = $this->get_reel_source_meta_path($token); if (!is_readable($path)) { return null; } $decoded = json_decode((string) file_get_contents($path), true); return is_array($decoded) ? $decoded : null; } private function write_reel_source_meta($token, array $meta) { file_put_contents($this->get_reel_source_meta_path($token), wp_json_encode($meta)); } private function resolve_reel_source_path($token, $user_id) { if (!preg_match('/^[a-zA-Z0-9]{16}$/', $token)) { return null; } $meta = $this->read_reel_source_meta($token); if (!$meta || (int) ($meta['user_id'] ?? 0) !== (int) $user_id) { return null; } $created = (int) ($meta['created'] ?? 0); if ($created > 0 && (time() - $created) > self::REEL_SOURCE_TTL) { return null; } $ext = isset($meta['ext']) ? (string) $meta['ext'] : ''; if ($ext === '') { return null; } $path = $this->get_reel_source_video_path($user_id, $token, $ext); if (!is_readable($path)) { return null; } return $path; } private function register_reel_source($user_id, $source_path, $ext, $duration) { $token = wp_generate_password(16, false, false); $dest = $this->get_reel_source_video_path($user_id, $token, $ext); if (!@copy($source_path, $dest)) { return new WP_Error('source', 'No se pudo guardar el video base para re-render.'); } $dimensions = $this->get_video_source_dimensions($dest); $meta = array( 'user_id' => (int) $user_id, 'ext' => $ext, 'duration' => (float) $duration, 'created' => time(), ); if ($dimensions['width'] > 0 && $dimensions['height'] > 0) { $meta['width'] = $dimensions['width']; $meta['height'] = $dimensions['height']; } $this->write_reel_source_meta($token, $meta); return $token; } private function get_reel_progress_path($job_id) { return $this->get_reels_work_dir() . '/progress-' . $job_id . '.txt'; } private function read_reel_job_progress($job_id, $expected_duration) { if ($job_id === '' || !preg_match('/^[a-zA-Z0-9]{16}$/', $job_id)) { return null; } $path = $this->get_reel_progress_path($job_id); if (!is_readable($path)) { return null; } $content = (string) file_get_contents($path); if ($content === '') { return null; } $time_seconds = 0.0; if (preg_match('/out_time_ms=(\d+)/', $content, $matches)) { $time_seconds = ((int) $matches[1]) / 1000000; } elseif (preg_match('/out_time=([0-9:.]+)/', $content, $matches)) { $parts = explode(':', $matches[1]); if (count($parts) === 3) { $time_seconds = ((int) $parts[0] * 3600) + ((int) $parts[1] * 60) + (float) $parts[2]; } } $expected_duration = max(1.0, (float) $expected_duration); $percent = (int) min(99, max(0, round(($time_seconds / $expected_duration) * 100))); return array( 'percent' => $percent, 'label' => $percent . '% · ' . number_format($time_seconds, 1) . 's / ' . number_format($expected_duration, 1) . 's', ); } private function build_reel_overlay_assets($headline, $title, $logo_path, $job_id) { $work_dir = $this->get_reels_work_dir(); $title_overlay_path = $work_dir . '/title-' . $job_id . '.png'; $logo_overlay_path = $work_dir . '/logo-' . $job_id . '.png'; $intro_logo_path = $work_dir . '/intro-logo-' . $job_id . '.png'; $intro_logo = $this->prepare_reel_logo_png($logo_path, $intro_logo_path, self::REEL_INTRO_LOGO_WIDTH); if (is_wp_error($intro_logo)) { return $intro_logo; } $title_overlay_result = $this->generate_reel_title_overlay($headline, $title, $intro_logo_path); if (is_wp_error($title_overlay_result)) { @unlink($intro_logo_path); return $title_overlay_result; } $title_overlay_y = (int) ($title_overlay_result['overlay_y'] ?? 0); $title_overlay_tmp = $title_overlay_result['path'] ?? ''; if ($title_overlay_tmp === '' || !@rename($title_overlay_tmp, $title_overlay_path)) { @unlink($intro_logo_path); if ($title_overlay_tmp !== '') { @unlink($title_overlay_tmp); } return new WP_Error('overlay', 'No se pudo guardar el overlay del titular.'); } $logo_overlay = $this->prepare_reel_logo_watermark($logo_path, $logo_overlay_path); @unlink($intro_logo_path); if (is_wp_error($logo_overlay)) { @unlink($title_overlay_path); return $logo_overlay; } return array( 'title_overlay_path' => $title_overlay_path, 'logo_overlay_path' => $logo_overlay_path, 'title_overlay_y' => $title_overlay_y, ); } private function parse_reel_clips_payload() { $raw = isset($_POST['clips_json']) ? wp_unslash($_POST['clips_json']) : ''; if ($raw === '') { return null; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : null; } private function get_reel_upload_ext_from_file(array $file) { $allowed_types = array( 'video/mp4' => 'mp4', 'video/quicktime' => 'mov', 'video/webm' => 'webm', ); $file_type = wp_check_filetype($file['name'] ?? '', array( 'mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'webm' => 'video/webm', )); $mime = !empty($file['type']) ? sanitize_mime_type($file['type']) : ''; return $file_type['ext'] ?: ($allowed_types[$mime] ?? ''); } private function resolve_reel_clip_upload($user_id, $index, array $clip_row) { $token = sanitize_text_field($clip_row['source_token'] ?? ''); if ($token !== '') { $path = $this->resolve_reel_source_path($token, $user_id); if (!$path) { return new WP_Error('source', 'Un video de la pista expiró. Vuelve a subirlo.'); } return array( 'path' => $path, 'source_token' => $token, ); } $file = null; if ($index === 0 && !empty($_FILES['video']['tmp_name']) && is_uploaded_file($_FILES['video']['tmp_name'])) { $file = $_FILES['video']; } else { $field = 'clip_video_' . (int) $index; if (!empty($_FILES[$field]['tmp_name']) && is_uploaded_file($_FILES[$field]['tmp_name'])) { $file = $_FILES[$field]; } } if (!$file) { return new WP_Error('video', 'Falta el video del clip #' . ((int) $index + 1) . '.'); } $ext = $this->get_reel_upload_ext_from_file($file); if ($ext === '') { return new WP_Error('video', 'Formato no soportado en el clip #' . ((int) $index + 1) . '.'); } $work_dir = $this->get_reels_work_dir(); $temp_path = $work_dir . '/upload-' . wp_generate_password(12, false, false) . '.' . $ext; if (!move_uploaded_file($file['tmp_name'], $temp_path)) { return new WP_Error('video', 'No se pudo guardar el clip #' . ((int) $index + 1) . '.'); } $source_duration = $this->get_video_source_duration($temp_path); if ($source_duration <= 0) { @unlink($temp_path); return new WP_Error('video', 'No se pudo leer la duración del clip #' . ((int) $index + 1) . '.'); } $token = $this->register_reel_source($user_id, $temp_path, $ext, $source_duration); @unlink($temp_path); if (is_wp_error($token)) { return $token; } $path = $this->resolve_reel_source_path($token, $user_id); if (!$path) { return new WP_Error('source', 'No se pudo preparar el clip #' . ((int) $index + 1) . '.'); } return array( 'path' => $path, 'source_token' => $token, ); } private function resolve_reel_clips_for_request($user_id, array $clips_payload) { if (empty($clips_payload)) { return new WP_Error('video', 'Debes añadir al menos un video a la pista.'); } if (count($clips_payload) > self::REEL_MAX_CLIPS) { return new WP_Error('video', 'Máximo ' . self::REEL_MAX_CLIPS . ' videos por Reel.'); } $resolved = array(); $total_duration = 0.0; foreach ($clips_payload as $index => $clip_row) { if (!is_array($clip_row)) { return new WP_Error('video', 'Datos de clip inválidos.'); } $upload = $this->resolve_reel_clip_upload($user_id, (int) $index, $clip_row); if (is_wp_error($upload)) { return $upload; } $source_duration = $this->get_video_source_duration($upload['path']); if ($source_duration <= 0) { return new WP_Error('video', 'No se pudo leer la duración del clip #' . ((int) $index + 1) . '.'); } $bounds = $this->normalize_reel_clip_bounds( isset($clip_row['clip_start']) ? (float) $clip_row['clip_start'] : 0.0, isset($clip_row['clip_end']) ? (float) $clip_row['clip_end'] : 0.0, $source_duration ); $frame_pan = $this->normalize_reel_frame_pan($clip_row['frame_pan'] ?? 0.5); $clip_duration = $this->get_reel_clip_duration($source_duration, $bounds['clip_start'], $bounds['clip_end']); $total_duration += $clip_duration; if ($total_duration > self::REEL_MAX_DURATION) { return new WP_Error('video', 'La pista supera los 90 segundos en total.'); } $source_is_normalized = false; $path = $upload['path']; if ($upload['source_token'] !== '' && $this->reel_uses_default_frame($bounds['clip_start'], $bounds['clip_end'], $frame_pan, $source_duration)) { $path = $this->ensure_reel_source_normalized($path, $user_id, $upload['source_token']); $source_is_normalized = substr($path, -9) === '.norm.mp4'; } $resolved[] = array( 'path' => $path, 'source_token' => $upload['source_token'], 'clip_start' => $bounds['clip_start'], 'clip_end' => $bounds['clip_end'], 'frame_pan' => $frame_pan, 'source_duration' => $source_duration, 'clip_duration' => $clip_duration, 'has_audio' => $this->video_has_audio_stream($path), 'source_is_normalized' => $source_is_normalized, ); } return $resolved; } private function start_reel_job_from_clips( array $resolved_clips, $headline, $title, $title_duration, $video_repetitions, $quality ) { $title = trim($title); if ($title === '') { return new WP_Error('title', 'Escribe el titular del Reel.'); } if (empty($resolved_clips)) { return new WP_Error('video', 'Debes añadir al menos un video a la pista.'); } $video_paths = array(); $multi_clips = array(); $total_duration = 0.0; $is_multi = count($resolved_clips) > 1; $video_repetitions = $is_multi ? 1 : $this->normalize_video_repetitions($video_repetitions); foreach ($resolved_clips as $index => $clip) { $video_paths[] = $clip['path']; $multi_clips[] = array( 'input_index' => $index, 'clip_start' => $clip['clip_start'], 'clip_end' => $clip['clip_end'], 'frame_pan' => $clip['frame_pan'], 'clip_duration' => $clip['clip_duration'], 'has_audio' => !empty($clip['has_audio']), ); $total_duration += $clip['clip_duration']; } if ($is_multi) { $video_duration = min($total_duration, (float) self::REEL_MAX_DURATION); } else { $clip = $resolved_clips[0]; $video_duration = $this->get_reel_output_duration( $clip['source_duration'], $video_repetitions, $clip['clip_start'], $clip['clip_end'] ); } $timing = $this->get_reel_timing($title_duration); $quality = $this->normalize_reel_quality($quality); $logo_path = $this->download_reels_logo_png(); if (is_wp_error($logo_path)) { return $logo_path; } $work_dir = $this->get_reels_work_dir(); $job_id = wp_generate_password(16, false, false); $assets = $this->build_reel_overlay_assets($headline, $title, $logo_path, $job_id); if (is_wp_error($assets)) { return $assets; } $filename = 'reel-vln-' . gmdate('Ymd-His') . '-' . wp_generate_password(6, false) . '.mp4'; $output_path = $work_dir . '/' . $filename; $url = $this->get_reel_download_url($filename, true); $clips_meta = array(); foreach ($resolved_clips as $clip) { $clips_meta[] = array( 'source_token' => $clip['source_token'], 'clip_start' => $clip['clip_start'], 'clip_end' => $clip['clip_end'], 'frame_pan' => $clip['frame_pan'], ); } $meta = array( 'headline' => sanitize_text_field($headline), 'title' => sanitize_textarea_field($title), 'title_duration' => $timing['title_duration'], 'video_repetitions' => $video_repetitions, 'quality' => $quality, 'clips' => $clips_meta, ); $first_clip = $resolved_clips[0]; $frame_options = array( 'clip_start' => $first_clip['clip_start'], 'clip_end' => $first_clip['clip_end'], 'frame_pan' => $first_clip['frame_pan'], 'video_repetitions' => $video_repetitions, 'source_is_normalized' => !empty($first_clip['source_is_normalized']), 'source_duration' => $first_clip['source_duration'], 'has_audio' => !empty($first_clip['has_audio']), 'multi_clips' => $is_multi ? $multi_clips : array(), ); $started = $this->start_reel_background_job( $job_id, $video_paths, $assets['title_overlay_path'], $assets['logo_overlay_path'], $output_path, $filename, $url, $meta, $timing, $video_duration, $video_repetitions, $assets['title_overlay_y'], $quality, $frame_options ); if (is_wp_error($started)) { @unlink($assets['title_overlay_path']); @unlink($assets['logo_overlay_path']); return $started; } return array( 'job_id' => $job_id, 'status' => 'processing', 'filename' => $filename, 'headline' => $meta['headline'], 'title' => $meta['title'], 'title_duration' => $timing['title_duration'], 'video_repetitions' => $video_repetitions, 'quality' => $quality, 'clips' => array_map(function ($clip) { return array( 'source_token' => $clip['source_token'], 'clip_start' => $clip['clip_start'], 'clip_end' => $clip['clip_end'], 'frame_pan' => $clip['frame_pan'], ); }, $clips_meta), 'duration' => $video_duration, ); } private function parse_reel_frame_request($source_duration) { $clip_start = isset($_POST['clip_start']) ? (float) wp_unslash($_POST['clip_start']) : 0.0; $clip_end = isset($_POST['clip_end']) ? (float) wp_unslash($_POST['clip_end']) : 0.0; $frame_pan = isset($_POST['frame_pan']) ? (float) wp_unslash($_POST['frame_pan']) : 0.5; $bounds = $this->normalize_reel_clip_bounds($clip_start, $clip_end, $source_duration); return array( 'clip_start' => $bounds['clip_start'], 'clip_end' => $bounds['clip_end'], 'frame_pan' => $this->normalize_reel_frame_pan($frame_pan), ); } private function start_reel_job_from_input( $input_path, $headline, $title, $title_duration, $video_repetitions, $quality, $source_token = '', array $frame_request = array() ) { $title = trim($title); if ($title === '') { return new WP_Error('title', 'Escribe el titular del Reel.'); } if (!is_readable($input_path)) { return new WP_Error('video', 'No se encontró el video base.'); } $user_id = get_current_user_id(); $raw_duration = $this->get_video_source_duration($input_path); if ($raw_duration <= 0) { return new WP_Error('video', 'No se pudo leer la duración del video.'); } $frame_request = wp_parse_args($frame_request, array( 'clip_start' => 0.0, 'clip_end' => 0.0, 'frame_pan' => 0.5, )); $bounds = $this->normalize_reel_clip_bounds( $frame_request['clip_start'], $frame_request['clip_end'], $raw_duration ); $frame_pan = $this->normalize_reel_frame_pan($frame_request['frame_pan']); $source_is_normalized = false; if ($source_token !== '' && $this->reel_uses_default_frame($bounds['clip_start'], $bounds['clip_end'], $frame_pan, $raw_duration)) { $input_path = $this->ensure_reel_source_normalized($input_path, $user_id, $source_token); $source_is_normalized = substr($input_path, -9) === '.norm.mp4'; } $source_duration = $this->get_video_source_duration($input_path); if ($source_duration <= 0) { return new WP_Error('video', 'No se pudo leer la duración del video.'); } $video_repetitions = $this->normalize_video_repetitions($video_repetitions); $video_duration = $this->get_reel_output_duration( $source_duration, $video_repetitions, $bounds['clip_start'], $bounds['clip_end'] ); $timing = $this->get_reel_timing($title_duration); $quality = $this->normalize_reel_quality($quality); $logo_path = $this->download_reels_logo_png(); if (is_wp_error($logo_path)) { return $logo_path; } $work_dir = $this->get_reels_work_dir(); $job_id = wp_generate_password(16, false, false); $assets = $this->build_reel_overlay_assets($headline, $title, $logo_path, $job_id); if (is_wp_error($assets)) { return $assets; } $filename = 'reel-vln-' . gmdate('Ymd-His') . '-' . wp_generate_password(6, false) . '.mp4'; $output_path = $work_dir . '/' . $filename; $url = $this->get_reel_download_url($filename, true); $meta = array( 'headline' => sanitize_text_field($headline), 'title' => sanitize_textarea_field($title), 'title_duration' => $timing['title_duration'], 'video_repetitions' => $video_repetitions, 'quality' => $quality, 'clip_start' => $bounds['clip_start'], 'clip_end' => $bounds['clip_end'], 'frame_pan' => $frame_pan, ); $frame_options = array( 'clip_start' => $bounds['clip_start'], 'clip_end' => $bounds['clip_end'], 'frame_pan' => $frame_pan, 'video_repetitions' => $video_repetitions, 'source_is_normalized' => $source_is_normalized, 'source_duration' => $source_duration, 'has_audio' => $this->video_has_audio_stream($input_path), ); $started = $this->start_reel_background_job( $job_id, array($input_path), $assets['title_overlay_path'], $assets['logo_overlay_path'], $output_path, $filename, $url, $meta, $timing, $video_duration, $video_repetitions, $assets['title_overlay_y'], $quality, $frame_options ); if (is_wp_error($started)) { @unlink($assets['title_overlay_path']); @unlink($assets['logo_overlay_path']); return $started; } return array( 'job_id' => $job_id, 'status' => 'processing', 'filename' => $filename, 'headline' => $meta['headline'], 'title' => $meta['title'], 'title_duration' => $timing['title_duration'], 'video_repetitions' => $video_repetitions, 'quality' => $quality, 'source_token' => $source_token, 'duration' => $video_duration, 'clip_start' => $bounds['clip_start'], 'clip_end' => $bounds['clip_end'], 'frame_pan' => $frame_pan, ); } public function ajax_download_reel() { while (ob_get_level() > 0) { ob_end_clean(); } $filename = basename(wp_unslash($_GET['file'] ?? '')); $user_id = isset($_GET['uid']) ? (int) $_GET['uid'] : 0; $token = wp_unslash($_GET['token'] ?? ''); if (!$this->verify_reel_download_token($filename, $token, $user_id)) { status_header(403); exit; } $path = $this->get_reels_work_dir() . '/' . $filename; if (!is_readable($path)) { status_header(404); exit; } $inline = !empty($_GET['inline']); $disposition = $inline ? 'inline' : 'attachment'; $size = filesize($path); nocache_headers(); header('Content-Type: video/mp4'); header('Content-Length: ' . $size); header('Accept-Ranges: bytes'); header('Content-Disposition: ' . $disposition . '; filename="' . $filename . '"'); header('X-Content-Type-Options: nosniff'); $handle = fopen($path, 'rb'); if ($handle) { while (!feof($handle)) { echo fread($handle, 8192); flush(); } fclose($handle); } exit; } private function get_reel_job_path($job_id) { return $this->get_reels_work_dir() . '/job-' . $job_id . '.json'; } private function write_reel_job_status($job_id, array $data) { $path = $this->get_reel_job_path($job_id); file_put_contents($path, wp_json_encode($data)); } private function read_reel_job_status($job_id) { $path = $this->get_reel_job_path($job_id); if (!is_readable($path)) { return null; } $decoded = json_decode((string) file_get_contents($path), true); return is_array($decoded) ? $decoded : null; } private function build_reel_ffmpeg_command(array $video_paths, $title_overlay_path, $logo_overlay_path, $output_path, array $timing, $duration, $video_repetitions = 1, $title_overlay_y = 0, $quality = 'final', $progress_path = '', array $frame_options = array()) { $duration = max(1, (float) $duration); $video_repetitions = $this->normalize_video_repetitions($video_repetitions); $video_paths = array_values(array_filter($video_paths, 'is_string')); if (empty($video_paths)) { return ''; } $video_inputs = array(); foreach ($video_paths as $video_path) { $video_inputs[] = '-i ' . escapeshellarg($video_path); } $video_input = implode(' ', $video_inputs); $profile = $this->get_reel_encode_profile($quality); $scale_w = (int) $profile['width']; $scale_h = (int) $profile['height']; $title_overlay_y = max(0, (int) round($title_overlay_y * ($scale_h / 1920))); $watermark_width = $this->get_reel_watermark_width($scale_w); $audio_args = $this->get_reel_audio_encoding_args($video_paths[0]); $progress_args = $progress_path !== '' ? '-progress ' . escapeshellarg($progress_path) . ' -nostats' : '-nostats'; $title_input_index = count($video_paths); $logo_input_index = count($video_paths) + 1; $frame_options = array_merge(array( 'clip_start' => 0, 'clip_end' => 0, 'frame_pan' => 0.5, 'video_repetitions' => $video_repetitions, 'source_is_normalized' => false, 'source_duration' => 0, 'has_audio' => $this->video_has_audio_stream($video_paths[0]), 'multi_clips' => array(), 'title_input_index' => $title_input_index, 'logo_input_index' => $logo_input_index, ), $frame_options); $frame_options['video_repetitions'] = $video_repetitions; $frame_options['title_input_index'] = $title_input_index; $frame_options['logo_input_index'] = $logo_input_index; $filter_result = $this->build_reel_filter_graph( $timing, $title_overlay_y, $scale_w, $scale_h, $watermark_width, self::REEL_LOGO_OPACITY, $frame_options ); $encode_args = sprintf( '-c:v libx264 -preset %s -crf %d -threads %d -x264-params %s -pix_fmt yuv420p %s -movflags +faststart', $profile['preset'], $profile['crf'], self::REEL_FFMPEG_THREADS, $profile['x264_params'], $audio_args ); return sprintf( 'ffmpeg -nostdin -y %s -loop 1 -i %s -loop 1 -i %s -filter_complex %s -map [outv]%s %s %s -t %s %s', $video_input, escapeshellarg($title_overlay_path), escapeshellarg($logo_overlay_path), escapeshellarg($filter_result['filter']), $filter_result['audio_map'], $encode_args, $progress_args, escapeshellarg(number_format($duration, 3, '.', '')), escapeshellarg($output_path) ); } private function start_reel_background_job($job_id, array $video_paths, $title_overlay_path, $logo_overlay_path, $output_path, $filename, $url, array $meta, array $timing, $duration, $video_repetitions = 1, $title_overlay_y = 0, $quality = 'final', array $frame_options = array()) { $work_dir = $this->get_reels_work_dir(); $status_file = $this->get_reel_job_path($job_id); $log_file = $work_dir . '/job-' . $job_id . '.log'; $script_path = $work_dir . '/job-' . $job_id . '.sh'; $user_id = get_current_user_id(); $progress_file = $this->get_reel_progress_path($job_id); @unlink($progress_file); $this->write_reel_job_status($job_id, array( 'status' => 'processing', 'job_id' => $job_id, 'filename' => $filename, 'url' => $url, 'user_id' => $user_id, 'created' => time(), 'duration' => (float) $duration, 'progress' => 0, )); $ffmpeg_cmd = $this->build_reel_ffmpeg_command( $video_paths, $title_overlay_path, $logo_overlay_path, $output_path, $timing, $duration, $video_repetitions, $title_overlay_y, $quality, $progress_file, $frame_options ); $done_payload = array_merge($meta, array( 'status' => 'done', 'filename' => $filename, 'url' => $url, 'user_id' => $user_id, 'created' => time(), )); $done_json = wp_json_encode($done_payload); $error_json = wp_json_encode(array( 'status' => 'error', 'error' => 'No se pudo procesar el video con ffmpeg.', 'user_id' => $user_id, 'created' => time(), )); $primary_input = !empty($video_paths[0]) ? $video_paths[0] : ''; $script = "#!/bin/bash\n"; $script .= 'STATUS=' . escapeshellarg($status_file) . "\n"; $script .= 'OUTPUT=' . escapeshellarg($output_path) . "\n"; $script .= 'INPUT=' . escapeshellarg($primary_input) . "\n"; $script .= 'TITLE_OVERLAY=' . escapeshellarg($title_overlay_path) . "\n"; $script .= 'LOGO_OVERLAY=' . escapeshellarg($logo_overlay_path) . "\n"; $script .= 'LOG=' . escapeshellarg($log_file) . "\n"; $script .= 'PROGRESS=' . escapeshellarg($progress_file) . "\n"; $script .= $ffmpeg_cmd . ' >> "$LOG" 2>&1' . "\n"; $script .= 'if [ -f "$OUTPUT" ] && [ "$(stat -c%s "$OUTPUT" 2>/dev/null || echo 0)" -gt 1000 ]; then' . "\n"; $script .= ' echo ' . escapeshellarg($done_json) . ' > "$STATUS"' . "\n"; if (!empty($meta)) { $meta_path = $this->get_reel_meta_path($filename); $script .= ' echo ' . escapeshellarg(wp_json_encode($meta)) . ' > ' . escapeshellarg($meta_path) . "\n"; } $script .= 'else' . "\n"; $script .= ' echo ' . escapeshellarg($error_json) . ' > "$STATUS"' . "\n"; $script .= ' rm -f "$OUTPUT"' . "\n"; $script .= 'fi' . "\n"; $script .= 'rm -f "$TITLE_OVERLAY" "$LOGO_OVERLAY" "$PROGRESS" "$0"' . "\n"; if (false === file_put_contents($script_path, $script)) { return new WP_Error('job', 'No se pudo crear el script de procesamiento.'); } chmod($script_path, 0755); exec('nohup ' . escapeshellarg($script_path) . ' > /dev/null 2>&1 &'); return true; } private function verify_reel_ajax_request() { if (!$this->current_user_can_access_reels()) { wp_send_json_error('Sin permiso para usar el editor de Reels.', 403); } if (!wp_verify_nonce($_POST['nonce'] ?? '', 'vln_reels_nonce')) { wp_send_json_error('Error de seguridad'); } } public function ajax_process_reel() { $this->verify_reel_ajax_request(); $headline = sanitize_text_field(wp_unslash($_POST['headline'] ?? '')); $title = sanitize_textarea_field(wp_unslash($_POST['title'] ?? '')); $title_duration = isset($_POST['title_duration']) ? (int) $_POST['title_duration'] : self::REEL_TITLE_DEFAULT_DURATION; $video_repetitions = $this->normalize_video_repetitions($_POST['video_repetitions'] ?? 1); $quality = $this->normalize_reel_quality(wp_unslash($_POST['quality'] ?? 'final')); $user_id = get_current_user_id(); @set_time_limit(120); $clips_payload = $this->parse_reel_clips_payload(); if (!is_array($clips_payload) || empty($clips_payload)) { wp_send_json_error('Debes añadir al menos un video a la pista.'); } $resolved = $this->resolve_reel_clips_for_request($user_id, $clips_payload); if (is_wp_error($resolved)) { wp_send_json_error($resolved->get_error_message()); } $result = $this->start_reel_job_from_clips( $resolved, $headline, $title, $title_duration, $video_repetitions, $quality ); if (is_wp_error($result)) { wp_send_json_error($result->get_error_message()); } wp_send_json_success($result); } public function ajax_rerender_reel() { $this->verify_reel_ajax_request(); $headline = sanitize_text_field(wp_unslash($_POST['headline'] ?? '')); $title = sanitize_textarea_field(wp_unslash($_POST['title'] ?? '')); $title_duration = isset($_POST['title_duration']) ? (int) $_POST['title_duration'] : self::REEL_TITLE_DEFAULT_DURATION; $video_repetitions = $this->normalize_video_repetitions($_POST['video_repetitions'] ?? 1); $quality = $this->normalize_reel_quality(wp_unslash($_POST['quality'] ?? 'final')); $user_id = get_current_user_id(); $clips_payload = $this->parse_reel_clips_payload(); if (!is_array($clips_payload) || empty($clips_payload)) { wp_send_json_error('No hay videos en la pista para re-renderizar.'); } foreach ($clips_payload as $clip_row) { if (empty($clip_row['source_token'])) { wp_send_json_error('Un clip de la pista no tiene video guardado. Vuelve a subirlo.'); } } $resolved = $this->resolve_reel_clips_for_request($user_id, $clips_payload); if (is_wp_error($resolved)) { wp_send_json_error($resolved->get_error_message()); } $result = $this->start_reel_job_from_clips( $resolved, $headline, $title, $title_duration, $video_repetitions, $quality ); if (is_wp_error($result)) { wp_send_json_error($result->get_error_message()); } wp_send_json_success($result); } public function ajax_poll_reel() { $this->verify_reel_ajax_request(); $job_id = sanitize_text_field(wp_unslash($_POST['job_id'] ?? '')); if ($job_id === '' || !preg_match('/^[a-zA-Z0-9]{16}$/', $job_id)) { wp_send_json_error('Trabajo de Reel inválido.'); } $status = $this->read_reel_job_status($job_id); if (!$status || (int) ($status['user_id'] ?? 0) !== get_current_user_id()) { wp_send_json_error('No se encontró el trabajo solicitado.'); } $status['job_id'] = $job_id; wp_send_json_success($this->enrich_reel_status_urls($status)); } } // Initialize the plugin https://www.vlnradio.cl/sitemap-root.xml 2026-07-08T19:12:39-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2026.xml 2026-07-08T17:35:57-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2025.xml 2025-08-14T12:07:14-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2024.xml 2025-07-04T14:16:40-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2023.xml 2025-08-11T01:47:42-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2022.xml 2025-02-03T23:07:26-03:00 https://www.vlnradio.cl/sitemap-posttype-post.2021.xml 2025-07-02T13:14:16-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2020.xml 2024-09-08T02:36:03-03:00 https://www.vlnradio.cl/sitemap-posttype-post.2019.xml 2025-08-06T21:43:32-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2018.xml 2025-08-06T15:49:20-04:00 https://www.vlnradio.cl/sitemap-posttype-post.2017.xml 2023-09-26T10:43:35-03:00 https://www.vlnradio.cl/sitemap-posttype-post.2016.xml 2025-06-26T13:57:12-04:00 https://www.vlnradio.cl/sitemap-posttype-page.xml 2025-08-05T19:44:56-04:00 https://www.vlnradio.cl/sitemap-posttype-podcast.2026.xml 2026-07-08T19:12:39-04:00 https://www.vlnradio.cl/sitemap-posttype-podcast.2025.xml 2025-08-27T16:52:43-04:00 https://www.vlnradio.cl/sitemap-posttype-podcast.2024.xml 2025-05-07T02:16:29-04:00 https://www.vlnradio.cl/sitemap-posttype-podcast.2023.xml 2023-12-05T12:23:22-03:00 https://www.vlnradio.cl/sitemap-taxonomy-category.xml 2026-07-08T17:35:57-04:00 https://www.vlnradio.cl/sitemap-taxonomy-post_tag.xml 2026-07-08T17:35:57-04:00 https://www.vlnradio.cl/sitemap-taxonomy-temas.xml 2026-07-08T17:35:57-04:00 https://www.vlnradio.cl/sitemap-taxonomy-autores.xml 2026-07-08T17:35:57-04:00 https://www.vlnradio.cl/sitemap-taxonomy-podcasts_tax.xml 2026-07-08T19:12:39-04:00 https://www.vlnradio.cl/sitemap-taxonomy-podcast_producers.xml 2026-07-08T19:12:39-04:00 https://www.vlnradio.cl/sitemap-taxonomy-podcast_hosts.xml 2026-07-08T19:12:39-04:00 https://www.vlnradio.cl/sitemap-author.xml 2026-07-08T19:12:39-04:00 https://www.vlnradio.cl/hemeroteca-sitemap.xml https://www.vlnradio.cl/clima-sitemap.xml https://www.vlnradio.cl/indicadores-sitemap.xml https://www.vlnradio.cl/farmacias-sitemap.xml