Preserve media alt text when regenerating an AI image

The regenerate job replaced the media item without carrying its meta, silently dropping the user's alt text (and slide metadata) from the persisted post.

Copy meta from the freshly-locked post row (not the pre-render snapshot) inside the transaction, so an alt edit made while the multi-second render runs is kept rather than overwritten.
This commit is contained in:
Paulo Castellano 2026-07-16 16:07:24 -03:00
parent cf045f2cdb
commit 949dfb5143
2 changed files with 62 additions and 0 deletions

View file

@ -282,6 +282,10 @@ private function replaceMediaOnPost(
throw new RuntimeException('Media item changed before regeneration completed.');
}
if (($meta = data_get($items->get($currentIndex), 'meta')) !== null) {
$newMediaItem['meta'] = $meta;
}
$items->put($currentIndex, $newMediaItem);
$fresh->update(['media' => $items->values()->all()]);

View file

@ -3,9 +3,11 @@
declare(strict_types=1);
use App\Jobs\Ai\RegeneratePostMediaImage;
use App\Models\Media;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
test('job fallback source context uses post content when source_meta is missing', function () {
$user = User::factory()->create();
@ -82,6 +84,62 @@
->and(data_get($copy, 'change_mode'))->toBe('image_only');
});
test('regenerating an image keeps the current saved alt text, not a pre-render snapshot', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'user_id' => $user->id,
'account_id' => $user->account_id,
]);
Storage::fake();
Storage::put('ai-images/regen.webp', 'fake-image-bytes');
$targetMedia = Media::factory()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $workspace->id,
'collection' => 'ai-generated',
]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'media' => [[
'id' => $targetMedia->id,
'type' => 'image',
'source' => 'ai',
'path' => 'ai-images/old.webp',
'url' => 'https://cdn.test/old.webp',
'meta' => ['alt_text' => 'CURRENT alt saved during regen', 'slide_title' => 'Intro'],
]],
]);
$job = new RegeneratePostMediaImage(
workspaceId: $workspace->id,
postId: $post->id,
userId: $user->id,
mediaId: $targetMedia->id,
regenerationId: 'regen-id',
instruction: 'image only tweak',
);
// Snapshot captured before the async render carries a now-stale alt.
$staleTarget = [
'id' => $targetMedia->id,
'type' => 'image',
'source' => 'ai',
'meta' => ['alt_text' => 'STALE alt from before regen'],
];
$rendered = ['path' => 'ai-images/regen.webp', 'source_meta' => []];
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'replaceMediaOnPost');
$method->setAccessible(true);
$method->invoke($job, $post, $staleTarget, $workspace, $rendered);
expect(data_get($post->fresh()->media, '0.meta.alt_text'))->toBe('CURRENT alt saved during regen')
->and(data_get($post->fresh()->media, '0.meta.slide_title'))->toBe('Intro');
});
test('merge structured copy keeps keywords unchanged for text_only mode', function () {
$job = new RegeneratePostMediaImage(
workspaceId: 'workspace-id',