option('dry-run'); $engineOverride = trim((string) $this->option('engine')); $idOption = trim((string) $this->option('id')); $limit = max(0, (int) $this->option('limit')); $includeFailed = (bool) $this->option('failed'); if ($engineOverride !== '' && ! in_array($engineOverride, [EnhanceJob::ENGINE_STUB, EnhanceJob::ENGINE_EXTERNAL_WORKER], true)) { $this->error("Unknown engine override: {$engineOverride}. Use 'stub' or 'external_worker'."); return self::FAILURE; } $jobs = $this->resolveJobs($idOption, $limit, $includeFailed); if ($jobs->isEmpty()) { $this->info('No eligible enhance jobs found.'); return self::SUCCESS; } $this->info(sprintf('Found %d job(s) to process.', $jobs->count())); $this->newLine(); if ($dryRun) { $this->warn('Dry-run mode — no jobs will be processed.'); foreach ($jobs as $job) { $engine = $engineOverride !== '' ? "{$engineOverride} (overridden)" : $job->engine; $this->line(sprintf( ' [dry-run] Job #%d status=%-12s engine=%-18s scale=%dx mode=%-14s user_id=%d', $job->id, $job->status, $engine, $job->scale, $job->mode, $job->user_id, )); } return self::SUCCESS; } $processed = 0; $failed = 0; foreach ($jobs as $job) { if ($this->processJob($job, $engineOverride)) { $processed++; } else { $failed++; } $this->newLine(); } $this->info(sprintf('Done: %d completed, %d failed.', $processed, $failed)); return $failed > 0 ? self::FAILURE : self::SUCCESS; } private function resolveJobs(string $idOption, int $limit, bool $includeFailed): Collection { if ($idOption !== '') { $ids = array_filter(array_map('intval', explode(',', $idOption))); return EnhanceJob::query() ->whereIn('id', $ids) ->whereIn('status', self::PROCESSABLE_STATUSES) ->get(); } $statuses = [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING]; if ($includeFailed) { $statuses[] = EnhanceJob::STATUS_FAILED; } $query = EnhanceJob::query()->whereIn('status', $statuses)->oldest(); if ($limit > 0) { $query->limit($limit); } return $query->get(); } private function processJob(EnhanceJob $job, string $engineOverride): bool { $engine = $engineOverride !== '' ? $engineOverride : (string) $job->engine; $this->line(sprintf('--- Job #%d ---', $job->id)); $this->line(sprintf(' Status : %s', $job->status)); $this->line(sprintf(' Engine : %s%s', $engine, $engineOverride !== '' ? ' (overridden)' : '')); $this->line(sprintf(' Scale : %dx', $job->scale)); $this->line(sprintf(' Mode : %s', $job->mode)); $this->line(sprintf(' User : #%d', $job->user_id)); if ($this->output->isVerbose()) { $this->line(sprintf( ' Source : disk=%-10s path=%s', $job->source_disk ?: '(default)', $job->source_path ?: '—', )); $this->line(sprintf( ' Input : %dx%d size=%s mime=%s', (int) $job->input_width, (int) $job->input_height, $this->formatBytes((int) $job->input_filesize), $job->input_mime ?: '—', )); if ($job->error_message !== null) { $this->warn(sprintf(' Previous error: %s', $job->error_message)); } } if (! in_array($job->status, self::PROCESSABLE_STATUSES, true)) { $this->warn(sprintf(' Skipping: status "%s" is not processable.', $job->status)); return false; } $job->forceFill([ 'status' => EnhanceJob::STATUS_PROCESSING, 'started_at' => now(), 'finished_at' => null, 'error_message' => null, ])->save(); $started = microtime(true); $completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30); try { $this->line(' Processing...'); $processor = $this->processorFactory->make($engine); $result = $processor->process($job); if ($this->output->isVerbose()) { $this->line(sprintf( ' Output : %dx%d size=%s mime=%s', $result->width, $result->height, $this->formatBytes($result->filesize), $result->mime, )); $this->line(sprintf( ' Stored : disk=%-10s path=%s', $result->disk, $result->path, )); } $this->line(' Generating preview...'); $preview = $this->storage->createPreviewFromStoredOutput($job, $result->disk, $result->path) ?? []; $outputHash = null; $outputContents = Storage::disk($result->disk)->get($result->path); if (is_string($outputContents) && $outputContents !== '') { $outputHash = hash('sha256', $outputContents); } $job->forceFill([ 'status' => EnhanceJob::STATUS_COMPLETED, 'output_disk' => $result->disk, 'output_path' => $result->path, 'output_hash' => $outputHash, 'output_width' => $result->width, 'output_height' => $result->height, 'output_filesize' => $result->filesize, 'output_mime' => $result->mime, 'metadata' => array_merge($job->metadata ?? [], $result->metadata ?? []), 'processing_seconds' => (int) round(microtime(true) - $started), 'finished_at' => now(), 'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null, ] + $preview)->save(); $elapsed = round(microtime(true) - $started, 2); $this->info(sprintf(' Completed in %.2fs', $elapsed)); if ($this->output->isVerbose() && ! empty($result->metadata)) { $this->line(' Metadata:'); foreach ($result->metadata as $key => $value) { $display = is_scalar($value) ? (string) $value : json_encode($value); $this->line(sprintf(' %-30s %s', $key . ':', $display)); } } return true; } catch (Throwable $exception) { $elapsed = round(microtime(true) - $started, 2); $job->forceFill([ 'status' => EnhanceJob::STATUS_FAILED, 'error_message' => Str::limit($exception->getMessage(), 1000), 'processing_seconds' => (int) round(microtime(true) - $started), 'finished_at' => now(), ])->save(); $this->error(sprintf(' Failed in %.2fs: %s', $elapsed, $exception->getMessage())); if ($this->output->isVerbose()) { $this->line(sprintf(' Exception : %s', get_class($exception))); $this->line(sprintf(' At : %s:%d', $exception->getFile(), $exception->getLine())); $previous = $exception->getPrevious(); if ($previous !== null) { $this->line(sprintf(' Caused by : %s: %s', get_class($previous), $previous->getMessage())); } } if ($this->output->isVeryVerbose()) { $this->line(' Stack trace:'); $frames = array_slice(explode("\n", $exception->getTraceAsString()), 0, 25); foreach ($frames as $frame) { $this->line(' ' . $frame); } } Log::warning('enhance.run.command.failed', [ 'enhance_job_id' => $job->id, 'engine' => $engine, 'message' => $exception->getMessage(), 'exception' => get_class($exception), ]); return false; } } private function formatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes . 'B'; } if ($bytes < 1_048_576) { return round($bytes / 1024, 1) . 'KB'; } return round($bytes / 1_048_576, 1) . 'MB'; } }