diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 11063a8b4..485cbd201 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -217,7 +217,7 @@ Sources: [stash hook](resources/js/hooks/use-stash.ts), [image storage](resource ### Images and descriptions -Attach up to **three JPEG, PNG, or WebP images** to a commit or reply. Each image can be at most **10 MB** and **6,000 pixels in either dimension**. Add an image description of up to **1,000 characters** for accessibility. Add images with the picker, by pasting, or by dropping them on the composer. Every image is re-encoded to WebP, at most 1,600 pixels wide, which strips metadata such as location. Images can be posted without accompanying text. Images on a pending commit are visible only to its author and admins. Image attachments and a poll cannot be combined in the same commit. Open an attached photo to view it in the photo viewer, move through the commit's images, read its description, or download it. Terminal commits can attach images without descriptions; the signed API cannot attach images. +Attach up to **three JPEG, PNG, GIF, or WebP images** to a commit or reply. Each image can be at most **10 MB** and **6,000 pixels in either dimension**. Add an image description of up to **1,000 characters** for accessibility. Add images with the picker, by pasting, or by dropping them on the composer. Every image is re-encoded to WebP, at most 1,600 pixels wide, which strips metadata such as location. GIFs are the exception: they are kept as GIFs at their original size so they stay animated. Messages and terminal commits do not take GIFs. Images can be posted without accompanying text. Images on a pending commit are visible only to its author and admins. Image attachments and a poll cannot be combined in the same commit. Open an attached photo to view it in the photo viewer, move through the commit's images, read its description, or download it. Terminal commits can attach images without descriptions; the signed API cannot attach images. Sources: [image picker](resources/js/components/commit-image-picker.tsx), [image limits](app/Actions/CreateCommitImages.php), [attachment validation](app/Http/Requests/CreateCommitRequest.php), [photo controller](app/Http/Controllers/CommitPhotoController.php). diff --git a/app/Actions/CreateCommitImageFiles.php b/app/Actions/CreateCommitImageFiles.php index 5b22ce88e..1de24e763 100644 --- a/app/Actions/CreateCommitImageFiles.php +++ b/app/Actions/CreateCommitImageFiles.php @@ -5,13 +5,14 @@ namespace App\Actions; use Illuminate\Http\UploadedFile; -use Illuminate\Image\Image as ProcessedImage; use Illuminate\Image\ImageException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Intervention\Gif\Decoder as GifDecoder; +use Intervention\Gif\Exceptions\GifException; final readonly class CreateCommitImageFiles { @@ -19,10 +20,11 @@ private const int QUALITY = 60; - private const array ALLOWED_TYPES = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP]; + private const array ALLOWED_TYPES = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF]; /** * Every image is decoded, scaled down, and re-encoded as WebP: metadata such as GPS location is dropped, and anything smuggled alongside the pixels is not kept. + * A GIF keeps its animation instead: re-encoding every frame takes gigabytes of memory, so its blocks are only parsed and written back, which drops bytes outside them. * All of them are encoded before any is stored, so a bad one leaves nothing behind, and a stored one is erased again if the transaction saving it rolls back. * * @param list $images @@ -36,15 +38,15 @@ public function handle(array $images, array $descriptions = [], string $director $files = []; foreach ($optimized as $position => $image) { - $path = $image->storeAs($directory, Str::uuid7()->toString().'.webp'); - assert(is_string($path)); + $path = $directory.'/'.Str::uuid7()->toString().'.'.$image['extension']; + Storage::disk()->put($path, $image['bytes']); DB::afterRollBack(fn (): bool => Storage::disk()->delete($path)); $files[] = [ 'path' => $path, - 'width' => $image->width(), - 'height' => $image->height(), + 'width' => $image['width'], + 'height' => $image['height'], 'description' => $descriptions[$position] ?? null, 'position' => $position, ]; @@ -55,8 +57,10 @@ public function handle(array $images, array $descriptions = [], string $director /** * GD is used explicitly since, unlike Imagick, it never writes the source's metadata back out. + * + * @return array{extension: string, bytes: string, width: int, height: int} */ - private function optimize(UploadedFile $file): ProcessedImage + private function optimize(UploadedFile $file): array { $info = @getimagesize($file->getRealPath()); @@ -67,6 +71,14 @@ private function optimize(UploadedFile $file): ProcessedImage throw $this->invalid(); } + if ($info[2] === IMAGETYPE_GIF) { + try { + return ['extension' => 'gif', 'bytes' => GifDecoder::decode($file->getRealPath())->encode(), 'width' => $info[0], 'height' => $info[1]]; + } catch (GifException) { + throw $this->invalid(); + } + } + $image = Image::fromUpload($file) ->usingGd() ->orient() @@ -74,18 +86,18 @@ private function optimize(UploadedFile $file): ProcessedImage ->optimize('webp', self::QUALITY); try { - $image->toBytes(); + $bytes = $image->toBytes(); } catch (ImageException) { throw $this->invalid(); } - return $image; + return ['extension' => 'webp', 'bytes' => $bytes, 'width' => $image->width(), 'height' => $image->height()]; } private function invalid(): ValidationException { return ValidationException::withMessages([ - 'images' => 'Images must be JPEG, PNG, or WebP files of at most '.CreateCommitImages::MAX_SOURCE_DIMENSION.' pixels per side.', + 'images' => 'Images must be JPEG, PNG, GIF, or WebP files of at most '.CreateCommitImages::MAX_SOURCE_DIMENSION.' pixels per side.', ]); } } diff --git a/app/Actions/CreateUserFeed.php b/app/Actions/CreateUserFeed.php index 145731b7a..4f1c6b8d3 100644 --- a/app/Actions/CreateUserFeed.php +++ b/app/Actions/CreateUserFeed.php @@ -68,7 +68,7 @@ private function entry(XMLWriter $xml, User $user, Commit $commit): void $this->link($xml, 'alternate', route('commit.show', [$user, $commit]), 'text/html'); foreach ($commit->images as $image) { - $this->link($xml, 'enclosure', $image->url(), 'image/webp'); + $this->link($xml, 'enclosure', $image->url(), $image->mimeType()); } $xml->writeElement('published', $commit->created_at->toAtomString()); diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 852116e1f..068a5ba16 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -9,6 +9,8 @@ use App\Models\CommitImage; use App\Services\OpenAI; use Illuminate\Support\Facades\Storage; +use Intervention\Gif\Decoder as GifDecoder; +use Intervention\Gif\DisposalMethod; final readonly class UpdateCommitAiDecision { @@ -17,6 +19,11 @@ */ private const float AUTO_REJECT_THRESHOLD = 0.95; + /** + * How many frames of a GIF are checked, spread evenly from its first to its last. + */ + private const int GIF_FRAMES = 10; + public function __construct( private OpenAI $openAI, private UpdateCommitStatuses $updateCommitStatuses, @@ -32,8 +39,8 @@ public function __construct( */ public function handle(Commit $commit): void { - $images = array_values($commit->images->map( - fn (CommitImage $image): string => 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), + $images = array_values($commit->images->flatMap( + $this->dataUrls(...), )->all()); $scores = $this->openAI->moderate($commit->body, $images); @@ -61,4 +68,64 @@ public function handle(Commit $commit): void $this->updateCommitStatuses->handle([$commit->id], CommitStatus::Approved, onlyPending: true); } } + + /** + * The AI does not take animated images, so a GIF is checked by its frames instead. + * + * @return list + */ + private function dataUrls(CommitImage $image): array + { + $bytes = (string) Storage::get($image->path); + + if ($image->mimeType() !== 'image/gif') { + return ['data:image/webp;base64,'.base64_encode($bytes)]; + } + + return array_map( + fn (string $frame): string => 'data:image/png;base64,'.base64_encode($frame), + $this->gifFrames($bytes), + ); + } + + /** + * Most frames only redraw part of the picture, so each is drawn over the ones before it, as a browser shows it. + * + * @return list + */ + private function gifFrames(string $bytes): array + { + $gif = GifDecoder::decode($bytes); + $frames = $gif->frames(); + $last = count($frames) - 1; + $checked = array_map(fn (int $step): int => (int) round($step * $last / (self::GIF_FRAMES - 1)), range(0, self::GIF_FRAMES - 1)); + + // A GIF may claim an empty screen, but its frames still draw onto at least one pixel. + $canvas = imagecreatetruecolor(max(1, $gif->logicalScreenDescriptor()->width()), max(1, $gif->logicalScreenDescriptor()->height())); + $pngs = []; + + foreach ($frames as $index => $frame) { + $descriptor = $frame->imageDescriptor(); + $disposal = $frame->graphicControlExtension()?->disposalMethod(); + $previous = $disposal === DisposalMethod::PREVIOUS ? imagecrop($canvas, ['x' => 0, 'y' => 0, 'width' => imagesx($canvas), 'height' => imagesy($canvas)]) : false; + + $image = imagecreatefromstring((clone $gif)->setFrames([$frame])->encode()); + assert($image !== false); + imagecopy($canvas, $image, $descriptor->left(), $descriptor->top(), 0, 0, imagesx($image), imagesy($image)); + + if (in_array($index, $checked, true)) { + ob_start(); + imagepng($canvas); + $pngs[] = (string) ob_get_clean(); + } + + if ($disposal === DisposalMethod::BACKGROUND) { + imagefilledrectangle($canvas, $descriptor->left(), $descriptor->top(), $descriptor->left() + $descriptor->width() - 1, $descriptor->top() + $descriptor->height() - 1, 0); + } elseif ($previous !== false) { + $canvas = $previous; + } + } + + return $pngs; + } } diff --git a/app/Http/Controllers/CommitImageController.php b/app/Http/Controllers/CommitImageController.php index 943b25213..54eef1e23 100644 --- a/app/Http/Controllers/CommitImageController.php +++ b/app/Http/Controllers/CommitImageController.php @@ -13,8 +13,8 @@ { public function show(ShowCommitImageRequest $request, CommitImage $commitImage): StreamedResponse { - return Storage::disk()->response($commitImage->path, $commitImage->id.'.webp', headers: [ - 'Content-Type' => 'image/webp', + return Storage::disk()->response($commitImage->path, $commitImage->id.'.'.pathinfo($commitImage->path, PATHINFO_EXTENSION), headers: [ + 'Content-Type' => $commitImage->mimeType(), 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, max-age=3600', diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index da3487990..0b6a65e17 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -51,7 +51,7 @@ public function rules(): array 'images.*' => [ 'required', 'image', - File::types(['image/jpeg', 'image/png', 'image/webp'])->max(CreateCommitImages::MAX_UPLOAD_SIZE), + File::types(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])->max(CreateCommitImages::MAX_UPLOAD_SIZE), Rule::dimensions() ->maxWidth(CreateCommitImages::MAX_SOURCE_DIMENSION) ->maxHeight(CreateCommitImages::MAX_SOURCE_DIMENSION), diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 2b0e62171..61260fe53 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -49,7 +49,7 @@ public static function normalizeBody(FormRequest $request): void * * @return array> */ - public static function attachmentRules(): array + public static function attachmentRules(bool $allowsGif = false): array { return [ 'poll' => ['nullable', 'array:options,duration', 'prohibits:parent_id,images'], @@ -60,7 +60,7 @@ public static function attachmentRules(): array 'images.*' => [ 'required', 'image', - File::types(['image/jpeg', 'image/png', 'image/webp'])->max(CreateCommitImages::MAX_UPLOAD_SIZE), + File::types(['image/jpeg', 'image/png', 'image/webp', ...($allowsGif ? ['image/gif'] : [])])->max(CreateCommitImages::MAX_UPLOAD_SIZE), Rule::dimensions() ->maxWidth(CreateCommitImages::MAX_SOURCE_DIMENSION) ->maxHeight(CreateCommitImages::MAX_SOURCE_DIMENSION), @@ -88,7 +88,7 @@ public function rules(): array 'body' => self::bodyRules($this->hasFile('images')), 'quoted_commit_id' => ['nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, 'That commit is no longer available to quote.')], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], - ...self::attachmentRules(), + ...self::attachmentRules(allowsGif: true), ]; } diff --git a/app/Models/CommitImage.php b/app/Models/CommitImage.php index 611dd8d4e..6f614e99b 100644 --- a/app/Models/CommitImage.php +++ b/app/Models/CommitImage.php @@ -78,4 +78,12 @@ public function url(): string { return route('commit-image.show', $this); } + + /** + * Images are stored as WebP, apart from GIFs, which are kept as they are to stay animated. + */ + public function mimeType(): string + { + return str_ends_with($this->path, '.gif') ? 'image/gif' : 'image/webp'; + } } diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 113f9bea1..dc4c35814 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -282,7 +282,7 @@ private function commitImages(Commit $commit, User $user): array 'url' => $image->url(), 'width' => $image->width, 'height' => $image->height, - 'type' => 'image/webp', + 'type' => $image->mimeType(), 'alt' => $count === 1 ? "Image attached to the commit of {$user->nameWithHandle()}" : 'Image '.($index + 1)." of {$count} attached to the commit of {$user->nameWithHandle()}", diff --git a/resources/js/lib/read-picked-images.ts b/resources/js/lib/read-picked-images.ts index d18db22b4..f48800b75 100644 --- a/resources/js/lib/read-picked-images.ts +++ b/resources/js/lib/read-picked-images.ts @@ -2,9 +2,14 @@ export class PickedImageError extends Error {} export const maxImages = 3; export const maxImageBytes = 10 * 1024 * 1024; -export const acceptedImageTypes = ['image/jpeg', 'image/png', 'image/webp']; +export const acceptedImageTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +]; export const invalidImageMessage = - 'Only JPEG, PNG, or WebP images up to 10 MB.'; + 'Only JPEG, PNG, GIF, or WebP images up to 10 MB.'; /** * Copy provider-backed files before reading metadata or creating preview URLs. diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 2f52437a9..d16a6a1ec 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1409,7 +1409,7 @@ expect($user->commits()->count())->toBe(0); }); -it('may only attach jpeg, png or webp images to a commit', function (UploadedFile $file): void { +it('may only attach jpeg, png, gif or webp images to a commit', function (UploadedFile $file): void { Storage::fake(); $user = User::factory()->create(); @@ -1427,7 +1427,6 @@ 'svg' => fn (): UploadedFile => UploadedFile::fake()->createWithContent('image.svg', ''), 'html disguised as png' => fn (): UploadedFile => UploadedFile::fake()->createWithContent('image.png', ''), 'bmp' => fn (): UploadedFile => UploadedFile::fake()->image('image.bmp', 10, 10), - 'gif' => fn (): UploadedFile => UploadedFile::fake()->image('image.gif', 10, 10), 'pdf' => fn (): UploadedFile => UploadedFile::fake()->create('document.pdf', 10, 'application/pdf'), ]); diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index f8cd2ae8c..9e915d850 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -35,6 +35,18 @@ function storedCommitImage(Commit $commit): CommitImage ->and($response->streamedContent())->toBe(Storage::disk()->get($image->path)); })->with(['default' => [[]], 'download disabled' => [['download' => '0']]]); +it('serves gifs as gifs', function (): void { + $path = 'commit-images/'.fake()->uuid().'.gif'; + Storage::disk()->put($path, (string) UploadedFile::fake()->image('image.gif', 10, 10)->getContent()); + $image = CommitImage::factory()->for(Commit::factory()->create())->create(['path' => $path]); + + $response = $this->get(route('commit-image.show', ['commitImage' => $image, 'download' => 1])); + + $response->assertOk() + ->assertDownload($image->id.'.gif') + ->assertHeader('Content-Type', 'image/gif'); +}); + it('downloads the stored image with a webp filename', function (): void { $image = storedCommitImage(Commit::factory()->create()); diff --git a/tests/Feature/Controllers/ConversationMessageControllerTest.php b/tests/Feature/Controllers/ConversationMessageControllerTest.php index f758ad103..238defa15 100644 --- a/tests/Feature/Controllers/ConversationMessageControllerTest.php +++ b/tests/Feature/Controllers/ConversationMessageControllerTest.php @@ -253,6 +253,21 @@ ->and($response->json('message.images.0.url'))->toBe(route('message-image.show', Message::query()->sole()->images->first())); })->with(['look at this', '']); +it('does not take gifs, which only commits keep', function (): void { + Storage::fake(); + + $mark = User::factory()->withRemotes()->create(); + $nuno = User::factory()->withRemotes()->create(); + + $this->actingAs($mark)->post(route('conversation-message.store', $nuno), [ + 'body' => '', + 'client_id' => Str::uuid()->toString(), + 'images' => [UploadedFile::fake()->image('animation.gif', 10, 10)], + ], ['Accept' => 'application/json'])->assertJsonValidationErrors('images.0'); + + expect(Storage::disk()->allFiles())->toBe([]); +}); + it('stores the images of a retried message only once', function (): void { Storage::fake(); diff --git a/tests/Unit/Actions/CreateCommitImagesTest.php b/tests/Unit/Actions/CreateCommitImagesTest.php index c393b0653..d966bfb54 100644 --- a/tests/Unit/Actions/CreateCommitImagesTest.php +++ b/tests/Unit/Actions/CreateCommitImagesTest.php @@ -137,11 +137,34 @@ ]); })->throws(ValidationException::class); -it('refuses formats other than jpeg, png and webp', function (string $name): void { +it('keeps gifs as gifs, dropping anything smuggled after them', function (): void { + $file = UploadedFile::fake()->image('animation.gif', 30, 20); + file_put_contents($file->getRealPath(), '', FILE_APPEND); + + $commit = Commit::factory()->create(); + + resolve(CreateCommitImages::class)->handle($commit, [$file]); + + $image = $commit->images()->sole(); + $contents = (string) Storage::disk()->get($image->path); + + expect($image->path)->toStartWith('commit-images/')->toEndWith('.gif') + ->and($image->only(['width', 'height']))->toBe(['width' => 30, 'height' => 20]) + ->and(getimagesizefromstring($contents)['mime'] ?? null)->toBe('image/gif') + ->and($contents)->not->toContain('createWithContent('broken.gif', "GIF89a\x0a\x00\x0a\x00\x00\x00\x00"); + + resolve(CreateCommitImages::class)->handle(Commit::factory()->create(), [$file]); +})->throws(ValidationException::class); + +it('refuses formats other than jpeg, png, gif and webp', function (string $name): void { $file = UploadedFile::fake()->image($name, 10, 10); resolve(CreateCommitImages::class)->handle(Commit::factory()->create(), [$file]); -})->throws(ValidationException::class)->with(['image.bmp', 'image.gif']); +})->throws(ValidationException::class)->with(['image.bmp']); it('refuses images too large to decode safely', function (): void { resolve(CreateCommitImages::class)->handle(Commit::factory()->create(), [UploadedFile::fake()->image('huge.png', 6001, 10)]); diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index 211caaf23..2812f2e7f 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -20,6 +20,8 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; +use Intervention\Gif\Builder; +use Intervention\Gif\DisposalMethod; beforeEach(function (): void { config(['services.openai.key' => 'sk-test']); @@ -266,6 +268,100 @@ function reviewResponses(array $responses = []): Closure && $request['text']['format']['name'] === 'harmful'); }); +/** + * @param list $frames + */ +function solidGif(int $width, int $height, array $frames): string +{ + $builder = Builder::canvas($width, $height); + + foreach ($frames as $frame) { + $image = imagecreate($frame['width'], $frame['height']); + imagecolorallocate($image, $frame['color'] >> 16, $frame['color'] >> 8 & 0xFF, $frame['color'] & 0xFF); + + ob_start(); + imagegif($image); + $builder->addFrame((string) ob_get_clean(), left: $frame['left'] ?? 0, top: $frame['top'] ?? 0); + } + + foreach ($builder->gifDataStream()->frames() as $index => $block) { + $block->graphicControlExtension()?->setDisposalMethod($frames[$index]['disposal'] ?? DisposalMethod::NONE); + } + + return $builder->encode(); +} + +/** + * @return list + */ +function moderatedImages(): array +{ + return array_values(Http::recorded(fn (Request $request): bool => str_ends_with($request->url(), '/moderations')) + ->map(function (array $pair): GdImage { + $url = $pair[0]['input'][0]['image_url']['url']; + expect($url)->toStartWith('data:image/png;base64,'); + + $image = imagecreatefromstring(base64_decode(mb_substr((string) $url, 22))); + assert($image instanceof GdImage); + + return $image; + }) + ->all()); +} + +it('checks the frames of a gif as they are shown, each drawn over the ones before it', function (): void { + Storage::fake(); + Storage::put('commit-images/animation.gif', solidGif(10, 10, [ + ['width' => 10, 'height' => 10, 'color' => 0xFF0000], + ['width' => 2, 'height' => 2, 'color' => 0x0000FF, 'left' => 5, 'top' => 5, 'disposal' => DisposalMethod::BACKGROUND], + ['width' => 2, 'height' => 2, 'color' => 0x00FF00, 'disposal' => DisposalMethod::PREVIOUS], + ['width' => 1, 'height' => 1, 'color' => 0xFFFFFF, 'left' => 9, 'top' => 9], + ])); + + Http::fake([ + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); + + $commit = Commit::factory()->pending()->create(['body' => '']); + CommitImage::factory()->for($commit)->create(['path' => 'commit-images/animation.gif', 'position' => 0]); + + dispatch_sync(new ModerateCommit($commit)); + + expect($commit->refresh()->ai_decision)->toBe(CommitStatus::Approved) + ->and(array_map(fn (GdImage $frame): array => [ + imagecolorat($frame, 0, 0), + imagecolorat($frame, 6, 6), + imagecolorat($frame, 9, 9), + ], moderatedImages()))->toBe([ + [0xFF0000, 0xFF0000, 0xFF0000], + [0xFF0000, 0x0000FF, 0xFF0000], + [0x00FF00, 0x000000, 0xFF0000], + [0xFF0000, 0x000000, 0xFFFFFF], + ]); +}); + +it('checks ten frames of a longer gif, from its first to its last', function (): void { + Storage::fake(); + Storage::put('commit-images/animation.gif', solidGif(4, 4, array_map( + fn (int $index): array => ['width' => 4, 'height' => 4, 'color' => $index * 20 << 16], + range(0, 11), + ))); + + Http::fake([ + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); + + $commit = Commit::factory()->pending()->create(['body' => '']); + CommitImage::factory()->for($commit)->create(['path' => 'commit-images/animation.gif', 'position' => 0]); + + dispatch_sync(new ModerateCommit($commit)); + + expect(array_map(fn (GdImage $frame): int => imagecolorat($frame, 0, 0) >> 16, moderatedImages())) + ->toBe([0, 20, 40, 80, 100, 120, 140, 180, 200, 220]); +}); + it('flags categories scoring above the strict thresholds, even when openai does not flag them', function (): void { Http::fake([ 'api.openai.com/v1/moderations' => Http::response(moderationResponse(scores: [ diff --git a/tests/Unit/ReadPickedImages.test.ts b/tests/Unit/ReadPickedImages.test.ts index b38265492..47dcf9789 100644 --- a/tests/Unit/ReadPickedImages.test.ts +++ b/tests/Unit/ReadPickedImages.test.ts @@ -71,7 +71,7 @@ void test('bounds oversized reads and cancels the provider stream', async () => void test('rejects empty and unsupported images', async () => { const { images, errors } = await readPickedImages([ new File([], 'empty.png', { type: 'image/png' }), - new File(['gif'], 'photo.gif', { type: 'image/gif' }), + new File(['bmp'], 'photo.bmp', { type: 'image/bmp' }), ]); assert.deepEqual(images, []); assert.match((errors[0] as Error).message, /empty/);