Upload beautify
This commit is contained in:
178
app/Jobs/GenerateArtworkEmbeddingJob.php
Normal file
178
app/Jobs/GenerateArtworkEmbeddingJob.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $artworkId,
|
||||
private readonly ?string $sourceHash = null,
|
||||
private readonly bool $force = false,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [2, 10, 30];
|
||||
}
|
||||
|
||||
public function handle(ArtworkEmbeddingClient $client): void
|
||||
{
|
||||
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->find($this->artworkId);
|
||||
if (! $artwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceHash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($this->sourceHash ?? $artwork->hash ?? '')));
|
||||
if ($sourceHash === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$model = (string) config('recommendations.embedding.model', 'clip');
|
||||
$modelVersion = (string) config('recommendations.embedding.model_version', 'v1');
|
||||
$algoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||
|
||||
if (! $this->force) {
|
||||
$existing = ArtworkEmbedding::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('model', $model)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$lockKey = $this->lockKey($artwork->id, $model, $modelVersion);
|
||||
if (! $this->acquireLock($lockKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$imageUrl = $this->buildImageUrl($sourceHash);
|
||||
if ($imageUrl === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$vector = $client->embed($imageUrl, (int) $artwork->id, $sourceHash);
|
||||
if ($vector === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = $this->normalize($vector);
|
||||
|
||||
ArtworkEmbedding::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'model' => $model,
|
||||
'model_version' => $modelVersion,
|
||||
],
|
||||
[
|
||||
'algo_version' => $algoVersion,
|
||||
'dim' => count($normalized),
|
||||
'embedding_json' => json_encode($normalized, JSON_THROW_ON_ERROR),
|
||||
'source_hash' => $sourceHash,
|
||||
'is_normalized' => true,
|
||||
'generated_at' => now(),
|
||||
'meta' => [
|
||||
'source' => 'clip',
|
||||
'image_variant' => (string) config('vision.image_variant', 'md'),
|
||||
],
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
$this->releaseLock($lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function normalize(array $vector): array
|
||||
{
|
||||
$sumSquares = 0.0;
|
||||
foreach ($vector as $value) {
|
||||
$sumSquares += ($value * $value);
|
||||
}
|
||||
|
||||
if ($sumSquares <= 0.0) {
|
||||
return $vector;
|
||||
}
|
||||
|
||||
$norm = sqrt($sumSquares);
|
||||
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
||||
}
|
||||
|
||||
private function buildImageUrl(string $hash): ?string
|
||||
{
|
||||
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||
$clean = str_pad($clean, 6, '0');
|
||||
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||
|
||||
return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp';
|
||||
}
|
||||
|
||||
private function lockKey(int $artworkId, string $model, string $version): string
|
||||
{
|
||||
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
||||
}
|
||||
|
||||
private function acquireLock(string $key): bool
|
||||
{
|
||||
try {
|
||||
$didSet = Redis::setnx($key, 1);
|
||||
if ($didSet) {
|
||||
Redis::expire($key, 1800);
|
||||
}
|
||||
return (bool) $didSet;
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseLock(string $key): void
|
||||
{
|
||||
try {
|
||||
Redis::del($key);
|
||||
} catch (\Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user