From a1bb014cc933dc12f46c31b9332f5951c60c7802 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 9 Nov 2025 23:40:47 +0100 Subject: [PATCH] Add smart cropping example to demo --- demo/.env | 1 + demo/assets/app.js | 1 + demo/assets/controllers.json | 9 ++ .../assets/controllers/dropzone_controller.js | 33 ++++++ demo/assets/icons/material-symbols/crop.svg | 1 + demo/assets/styles/crop.css | 18 +++ demo/composer.json | 4 + demo/config/bundles.php | 1 + demo/config/packages/ai.yaml | 11 ++ demo/config/packages/csrf.yaml | 11 ++ demo/config/packages/twig.yaml | 1 + demo/config/routes.yaml | 6 + demo/src/Audio/Chat.php | 1 + demo/src/Crop/CropForm.php | 56 +++++++++ demo/src/Crop/Image/Analyzer.php | 63 ++++++++++ demo/src/Crop/Image/RelevantArea.php | 51 ++++++++ demo/src/Crop/Image/Resampler.php | 93 +++++++++++++++ demo/src/Crop/ImageCropper.php | 31 +++++ demo/src/Crop/TwigComponent.php | 66 +++++++++++ demo/src/Video/TwigComponent.php | 2 + demo/symfony.lock | 15 +++ demo/templates/base.html.twig | 3 + demo/templates/components/crop.html.twig | 62 ++++++++++ demo/templates/crop.html.twig | 9 ++ demo/templates/index.html.twig | 111 +++++++++++------- demo/tests/SmokeTest.php | 2 +- 26 files changed, 617 insertions(+), 45 deletions(-) create mode 100644 demo/assets/controllers/dropzone_controller.js create mode 100644 demo/assets/icons/material-symbols/crop.svg create mode 100644 demo/assets/styles/crop.css create mode 100644 demo/config/packages/csrf.yaml create mode 100644 demo/src/Crop/CropForm.php create mode 100644 demo/src/Crop/Image/Analyzer.php create mode 100644 demo/src/Crop/Image/RelevantArea.php create mode 100644 demo/src/Crop/Image/Resampler.php create mode 100644 demo/src/Crop/ImageCropper.php create mode 100644 demo/src/Crop/TwigComponent.php create mode 100644 demo/templates/components/crop.html.twig create mode 100644 demo/templates/crop.html.twig diff --git a/demo/.env b/demo/.env index 9dceefa20..f1e4b7202 100644 --- a/demo/.env +++ b/demo/.env @@ -22,3 +22,4 @@ APP_SECRET=ccb9dca72dce53c683eaaf775bfdb253 CHROMADB_HOST=chromadb CHROMADB_PORT=8080 OPENAI_API_KEY=sk-... +HUGGINGFACE_API_KEY=hf-... diff --git a/demo/assets/app.js b/demo/assets/app.js index 30387104d..4831c1a21 100644 --- a/demo/assets/app.js +++ b/demo/assets/app.js @@ -3,6 +3,7 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import './styles/app.css'; import './styles/audio.css'; import './styles/blog.css'; +import './styles/crop.css'; import './styles/stream.css'; import './styles/youtube.css'; import './styles/video.css'; diff --git a/demo/assets/controllers.json b/demo/assets/controllers.json index 6dd960d42..c54f8b932 100644 --- a/demo/assets/controllers.json +++ b/demo/assets/controllers.json @@ -1,5 +1,14 @@ { "controllers": { + "@symfony/ux-dropzone": { + "dropzone": { + "enabled": true, + "fetch": "eager", + "autoimport": { + "@symfony/ux-dropzone/dist/style.min.css": true + } + } + }, "@symfony/ux-live-component": { "live": { "enabled": true, diff --git a/demo/assets/controllers/dropzone_controller.js b/demo/assets/controllers/dropzone_controller.js new file mode 100644 index 000000000..132c5800b --- /dev/null +++ b/demo/assets/controllers/dropzone_controller.js @@ -0,0 +1,33 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + connect() { + this.element.addEventListener('dropzone:change', this._onChange.bind(this)); + this.element.addEventListener('dropzone:clear', this._onClear); + } + + disconnect() { + this.element.removeEventListener('dropzone:change', this._onChange.bind(this)); + this.element.removeEventListener('dropzone:clear', this._onClear); + } + + async _onChange(event) { + const cropComponent = document.getElementById('crop-component').__component; + + cropComponent.set('imageData', await this.blobToBase64(event.detail)); + } + + _onClear(event) { + const cropComponent = document.getElementById('crop-component').__component; + + cropComponent.set('imageData', null); + } + + blobToBase64(blob) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => resolve(reader.result); + }); + } +} diff --git a/demo/assets/icons/material-symbols/crop.svg b/demo/assets/icons/material-symbols/crop.svg new file mode 100644 index 000000000..aa3cc9853 --- /dev/null +++ b/demo/assets/icons/material-symbols/crop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/demo/assets/styles/crop.css b/demo/assets/styles/crop.css new file mode 100644 index 000000000..50f260447 --- /dev/null +++ b/demo/assets/styles/crop.css @@ -0,0 +1,18 @@ +.crop { + body&, .card-img-top { + background: rgb(34,34,34); + background: linear-gradient(0deg, rgb(209, 14, 205) 0%, rgb(120, 21, 135) 100%); + } + + .card-img-top { + color: #ffffff; + } + + & footer { + color: #ffffff; + + a { + color: #ffffff; + } + } +} diff --git a/demo/composer.json b/demo/composer.json index 4df5c31c7..7036cf917 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -7,6 +7,7 @@ "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", + "ext-gd": "*", "codewithkyrian/chromadb-php": "^0.4.0", "league/commonmark": "^2.7", "mcp/sdk": "@dev", @@ -22,13 +23,16 @@ "symfony/dom-crawler": "~7.3.0", "symfony/dotenv": "~7.3.0", "symfony/flex": "^2.5", + "symfony/form": "~7.3.0", "symfony/framework-bundle": "~7.3.0", "symfony/http-client": "~7.3.0", "symfony/mcp-bundle": "@dev", + "symfony/mime": "~7.3.0", "symfony/monolog-bundle": "^3.10", "symfony/runtime": "~7.3.0", "symfony/twig-bundle": "~7.3.0", "symfony/uid": "~7.3.0", + "symfony/ux-dropzone": "^2.31", "symfony/ux-icons": "^2.25", "symfony/ux-live-component": "^2.25", "symfony/ux-turbo": "^2.25", diff --git a/demo/config/bundles.php b/demo/config/bundles.php index d092c0a39..530b42fc5 100644 --- a/demo/config/bundles.php +++ b/demo/config/bundles.php @@ -17,6 +17,7 @@ Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Symfony\UX\Dropzone\DropzoneBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], diff --git a/demo/config/packages/ai.yaml b/demo/config/packages/ai.yaml index 8d08897fd..c2b38bdbd 100644 --- a/demo/config/packages/ai.yaml +++ b/demo/config/packages/ai.yaml @@ -2,8 +2,11 @@ ai: platform: openai: api_key: '%env(OPENAI_API_KEY)%' + huggingface: + api_key: '%env(HUGGINGFACE_API_KEY)%' agent: blog: + platform: 'ai.platform.openai' model: 'gpt-4o-mini' tools: - 'Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch' @@ -12,14 +15,17 @@ ai: description: 'Provides the current date and time.' method: 'now' stream: + platform: 'ai.platform.openai' model: 'gpt-4o-mini' prompt: file: '%kernel.project_dir%/prompts/stream-chat.txt' tools: false youtube: + platform: 'ai.platform.openai' model: 'gpt-4o-mini' tools: false wikipedia: + platform: 'ai.platform.openai' model: name: 'gpt-4o-mini' options: @@ -31,6 +37,7 @@ ai: - 'Symfony\AI\Agent\Toolbox\Tool\Wikipedia' include_sources: true audio: + platform: 'ai.platform.openai' model: 'gpt-4o-mini?temperature=1.0' prompt: 'You are a friendly chatbot that likes to have a conversation with users and asks them some questions.' tools: @@ -39,14 +46,17 @@ ai: name: 'symfony_blog' description: 'Can answer questions based on the Symfony blog.' orchestrator: + platform: 'ai.platform.openai' model: 'gpt-4o-mini' prompt: 'You are an intelligent agent orchestrator that routes user questions to specialized agents.' tools: false technical: + platform: 'ai.platform.openai' model: 'gpt-4o-mini' prompt: 'You are a technical support specialist. Help users resolve bugs, problems, and technical errors.' tools: false fallback: + platform: 'ai.platform.openai' model: 'gpt-4o-mini' prompt: 'You are a helpful general assistant. Assist users with any questions or tasks they may have.' tools: false @@ -62,6 +72,7 @@ ai: collection: 'symfony_blog' vectorizer: openai: + platform: 'ai.platform.openai' model: 'text-embedding-ada-002' indexer: blog: diff --git a/demo/config/packages/csrf.yaml b/demo/config/packages/csrf.yaml new file mode 100644 index 000000000..40d40405e --- /dev/null +++ b/demo/config/packages/csrf.yaml @@ -0,0 +1,11 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + form: + csrf_protection: + token_id: submit + + csrf_protection: + stateless_token_ids: + - submit + - authenticate + - logout diff --git a/demo/config/packages/twig.yaml b/demo/config/packages/twig.yaml index 3f795d921..eb8250708 100644 --- a/demo/config/packages/twig.yaml +++ b/demo/config/packages/twig.yaml @@ -1,5 +1,6 @@ twig: file_name_pattern: '*.twig' + form_themes: [ 'bootstrap_5_layout.html.twig' ] when@test: twig: diff --git a/demo/config/routes.yaml b/demo/config/routes.yaml index ee7e75c02..e756f80a9 100644 --- a/demo/config/routes.yaml +++ b/demo/config/routes.yaml @@ -18,6 +18,12 @@ blog: template: 'chat.html.twig' context: { chat: 'blog' } +crop: + path: '/crop' + controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController' + defaults: + template: 'crop.html.twig' + stream: path: '/stream' controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController' diff --git a/demo/src/Audio/Chat.php b/demo/src/Audio/Chat.php index efd107351..ff40a335a 100644 --- a/demo/src/Audio/Chat.php +++ b/demo/src/Audio/Chat.php @@ -25,6 +25,7 @@ final class Chat private const SESSION_KEY = 'audio-chat'; public function __construct( + #[Autowire(service: 'ai.platform.openai')] private readonly PlatformInterface $platform, private readonly RequestStack $requestStack, #[Autowire(service: 'ai.agent.audio')] diff --git a/demo/src/Crop/CropForm.php b/demo/src/Crop/CropForm.php new file mode 100644 index 000000000..9ac29f236 --- /dev/null +++ b/demo/src/Crop/CropForm.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Crop; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\UX\Dropzone\Form\DropzoneType; + +final class CropForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('originalImage', DropzoneType::class, [ + 'label' => 'Image to crop', + 'attr' => [ + 'data-controller' => 'dropzone', + 'placeholder' => 'Drag and drop an image or click to browse', + ], + ]) + ->add('format', ChoiceType::class, [ + 'choices' => [ + 'Square (1:1)' => '1:1', + 'Landscape (16:9)' => '16:9', + 'Portrait (9:16)' => '9:16', + ], + 'expanded' => true, + 'multiple' => false, + ]) + ->add('width', ChoiceType::class, [ + 'choices' => [ + 'Small (400px)' => 400, + 'Medium (800px)' => 800, + 'Large (1200px)' => 1200, + ], + 'expanded' => true, + 'multiple' => false, + ]) + ; + } + + public function getBlockPrefix(): string + { + return ''; + } +} diff --git a/demo/src/Crop/Image/Analyzer.php b/demo/src/Crop/Image/Analyzer.php new file mode 100644 index 000000000..10d756ca8 --- /dev/null +++ b/demo/src/Crop/Image/Analyzer.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Crop\Image; + +use Symfony\AI\Platform\Bridge\HuggingFace\Output\ObjectDetectionResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Task; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final readonly class Analyzer +{ + public function __construct( + #[Autowire(service: 'ai.platform.huggingface')] + private PlatformInterface $platform, + ) { + } + + public function getRelevantArea(string $imageData): RelevantArea + { + $result = $this->platform->invoke('facebook/detr-resnet-50', Image::fromDataUrl($imageData), [ + 'task' => Task::OBJECT_DETECTION, + ])->asObject(); + + \assert($result instanceof ObjectDetectionResult); + + if ([] === $result->objects) { + throw new \RuntimeException('No objects detected.'); + } + + $init = $result->objects[0]; + $xMin = $init->xmin; + $yMin = $init->ymin; + $xMax = $init->xmax; + $yMax = $init->ymax; + + foreach ($result->objects as $object) { + if ($object->xmin < $xMin) { + $xMin = $object->xmin; + } + if ($object->ymin < $yMin) { + $yMin = $object->ymin; + } + if ($object->xmax > $xMax) { + $xMax = $object->xmax; + } + if ($object->ymax > $yMax) { + $yMax = $object->ymax; + } + } + + return new RelevantArea((int) $xMin, (int)$yMin, (int)$xMax, (int)$yMax); + } +} diff --git a/demo/src/Crop/Image/RelevantArea.php b/demo/src/Crop/Image/RelevantArea.php new file mode 100644 index 000000000..73839b962 --- /dev/null +++ b/demo/src/Crop/Image/RelevantArea.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Crop\Image; + +final readonly class RelevantArea +{ + public function __construct( + public int $xMin, + public int $yMin, + public int $xMax, + public int $yMax, + ) { + } + + /** + * @return int<1, max> + */ + public function getWidth(): int + { + $width = $this->xMax - $this->xMin; + + if ($width < 1) { + throw new \InvalidArgumentException('Width must be at least 1 pixel.'); + } + + return $width; + } + + /** + * @return int<1, max> + */ + public function getHeight(): int + { + $height = $this->yMax - $this->yMin; + + if ($height < 1) { + throw new \InvalidArgumentException('Height must be at least 1 pixel.'); + } + + return $height; + } +} diff --git a/demo/src/Crop/Image/Resampler.php b/demo/src/Crop/Image/Resampler.php new file mode 100644 index 000000000..e0942596e --- /dev/null +++ b/demo/src/Crop/Image/Resampler.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Crop\Image; + +final readonly class Resampler +{ + /** + * @param string $imageData base64-encoded image data + * @param RelevantArea $area detected relevant area + * @param string $format aspect ratio format (1:1, 16:9, 9:16) + * @param int $newWidth new width in pixels (400, 800 or 1200) + * + * @return string base64-encoded image data + */ + public function resample(string $imageData, RelevantArea $area, string $format, int $newWidth): string + { + $filePath = sys_get_temp_dir().'/'.uniqid('resample_', true); + file_put_contents($filePath, base64_decode(explode(',', $imageData, 2)[1])); + $mimeType = explode(';', explode(':', $imageData, 2)[1], 2)[0]; + + switch ($mimeType) { + case 'image/png': + $image = imagecreatefrompng($filePath); + break; + case 'image/jpeg': + $image = imagecreatefromjpeg($filePath); + break; + case 'image/gif': + $image = imagecreatefromgif($filePath); + break; + case 'image/vnd.wap.wbmp': + $image = imagecreatefromwbmp($filePath); + break; + case 'image/webp': + $image = imagecreatefromwebp($filePath); + break; + default: + throw new \InvalidArgumentException(\sprintf('Mime type "%s" is not supported', $mimeType)); + } + + if (false === $image) { + throw new \RuntimeException('Failed to create an image from the provided data.'); + } + + $cropped = imagecreatetruecolor($area->getWidth(), $area->getHeight()); + + if (false === $cropped) { + throw new \RuntimeException('Failed to create a true color image for cropping.'); + } + + imagecopy($cropped, $image, 0, 0, $area->xMin, $area->yMin, $area->getWidth(), $area->getHeight()); + + [$aspectWidth, $aspectHeight] = array_map(intval(...), explode(':', $format)); + $newHeight = (int) ($newWidth / $aspectWidth * $aspectHeight); + + if ($newHeight < 1 || $newWidth < 1) { + throw new \InvalidArgumentException('New dimensions must be at least 1 pixel.'); + } + + $resampled = imagecreatetruecolor($newWidth, $newHeight); + imagecopyresampled($resampled, $cropped, 0, 0, 0, 0, $newWidth, $newHeight, $area->getWidth(), $area->getHeight()); + + ob_start(); + match (explode(';', explode(':', $imageData, 2)[1], 2)[0]) { + 'image/png' => imagepng($resampled), + 'image/jpeg' => imagejpeg($resampled, null, 85), + 'image/gif' => imagegif($resampled), + 'image/webp' => imagewebp($resampled), + default => imagepng($resampled), + }; + $imageContent = ob_get_clean(); + + if (false === $imageContent) { + throw new \RuntimeException('Failed to capture the image output.'); + } + + imagedestroy($image); + imagedestroy($cropped); + imagedestroy($resampled); + unlink($filePath); + + return 'data:'.explode(';', explode(':', $imageData, 2)[1], 2)[0].';base64,'. base64_encode($imageContent); + } +} diff --git a/demo/src/Crop/ImageCropper.php b/demo/src/Crop/ImageCropper.php new file mode 100644 index 000000000..7171cecd9 --- /dev/null +++ b/demo/src/Crop/ImageCropper.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Crop; + +use App\Crop\Image\Analyzer; +use App\Crop\Image\Resampler; + +final readonly class ImageCropper +{ + public function __construct( + private Analyzer $analyzer, + private Resampler $resampler, + ) { + } + + public function crop(string $imageData, string $format, int $width): string + { + $relevantArea = $this->analyzer->getRelevantArea($imageData); + + return $this->resampler->resample($imageData, $relevantArea, $format, $width); + } +} diff --git a/demo/src/Crop/TwigComponent.php b/demo/src/Crop/TwigComponent.php new file mode 100644 index 000000000..25d702f17 --- /dev/null +++ b/demo/src/Crop/TwigComponent.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Crop; + +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormView; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; + +#[AsLiveComponent('crop')] +final class TwigComponent +{ + use DefaultActionTrait; + + #[LiveProp(writable: true)] + public ?string $originalImage = null; + + #[LiveProp(writable: true)] + public ?string $imageData = null; + + #[LiveProp(writable: true)] + public string $format = '1:1'; + + #[LiveProp(writable: true)] + public int $width = 800; + + #[LiveProp] + public ?string $croppedImage = null; + + public function __construct( + private readonly FormFactoryInterface $formFactory, + private readonly ImageCropper $imageCropper, + ) { + } + + public function getForm(): FormView + { + return $this->formFactory + ->create(CropForm::class, [ + 'format' => $this->format, + 'width' => $this->width, + ]) + ->createView(); + } + + #[LiveAction] + public function crop(): void + { + if (null === $this->imageData) { + throw new \RuntimeException('No image data to crop.'); + } + + $this->croppedImage = $this->imageCropper->crop($this->imageData, $this->format, $this->width); + } +} diff --git a/demo/src/Video/TwigComponent.php b/demo/src/Video/TwigComponent.php index d7fc5245b..135b1749e 100644 --- a/demo/src/Video/TwigComponent.php +++ b/demo/src/Video/TwigComponent.php @@ -15,6 +15,7 @@ use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\PlatformInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveArg; @@ -30,6 +31,7 @@ final class TwigComponent public string $caption = 'Please define an instruction and hit submit.'; public function __construct( + #[Autowire(service: 'ai.platform.openai')] private readonly PlatformInterface $platform, ) { } diff --git a/demo/symfony.lock b/demo/symfony.lock index 0388806d2..05c2c95d4 100644 --- a/demo/symfony.lock +++ b/demo/symfony.lock @@ -115,6 +115,18 @@ ".env" ] }, + "symfony/form": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b" + }, + "files": [ + "config/packages/csrf.yaml" + ] + }, "symfony/framework-bundle": { "version": "7.0", "recipe": { @@ -210,6 +222,9 @@ "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" } }, + "symfony/ux-dropzone": { + "version": "v2.31.0" + }, "symfony/ux-icons": { "version": "2.17", "recipe": { diff --git a/demo/templates/base.html.twig b/demo/templates/base.html.twig index d80b2ab9c..a11060dc0 100644 --- a/demo/templates/base.html.twig +++ b/demo/templates/base.html.twig @@ -31,6 +31,9 @@ + diff --git a/demo/templates/components/crop.html.twig b/demo/templates/components/crop.html.twig new file mode 100644 index 000000000..69a8935ae --- /dev/null +++ b/demo/templates/components/crop.html.twig @@ -0,0 +1,62 @@ +
+
+ {{ ux_icon('material-symbols:crop', { height: '32px', width: '32px' }) }} + Smart Image Cropping +
+
+
Upload
+ {% set form = this.form %} + {{ form_start(form, { + attr: { + 'data-action': 'live#action:prevent', + 'data-live-action-param': 'crop', + 'data-model': 'norender|*' + } + }) }} +
+
+ {{ form_row(form.originalImage) }} +
+
+ {{ form_row(form.format) }} +
+
+ {{ form_row(form.width) }} +
+
+
+ +
+ {{ form_end(form) }} +
+
+
+
Original Image
+
+ {% if imageData %} +
+ Original Image +
+ {% else %} +

No image uploaded yet.

+ {% endif %} +
+
+
+
Cropped Image
+
+ {% if croppedImage %} +
+ Cropped Image +
+ {% else %} +

No cropped image available yet.

+ {% endif %} +
+
+
+
+
diff --git a/demo/templates/crop.html.twig b/demo/templates/crop.html.twig new file mode 100644 index 000000000..54a6ac09f --- /dev/null +++ b/demo/templates/crop.html.twig @@ -0,0 +1,9 @@ +{% extends 'base.html.twig' %} + +{% block body_class 'crop' %} + +{% block content %} +
+ {{ component('crop') }} +
+{% endblock %} diff --git a/demo/templates/index.html.twig b/demo/templates/index.html.twig index 6c71583b3..e876eef62 100644 --- a/demo/templates/index.html.twig +++ b/demo/templates/index.html.twig @@ -13,7 +13,7 @@

Examples

-
+
{{ ux_icon('mdi:symfony', { height: '150px', width: '150px' }) }} @@ -32,7 +32,7 @@ {% endif %}
-
+
{{ ux_icon('bi:youtube', { height: '150px', width: '150px' }) }} @@ -51,7 +51,7 @@ {% endif %}
-
+
{{ ux_icon('mdi:wikipedia', { height: '150px', width: '150px' }) }} @@ -70,63 +70,86 @@ {% endif %}
-
-
-
-
+
+
- {{ ux_icon('iconoir:microphone-solid', { height: '150px', width: '150px' }) }} + {{ ux_icon('material-symbols:crop', { height: '150px', width: '150px' }) }}
-
Audio Bot
-

Simple demonstration of speech-to-text with Whisper in combination with GPT.

- Try Audio Bot +
Smart Image Cropping
+

AI-assisted image cropping to focus on key elements on the image while resizing.

+ Try Smart Image Cropping
{# Profiler route only available in dev #} {% if 'dev' == app.environment %} {% endif %}
-
-
-
- {{ ux_icon('tabler:video-filled', { height: '150px', width: '150px' }) }} -
-
-
Video Bot
-

Simple demonstration of vision capabilities of GPT in combination with your webcam.

- Try Video Bot -
- {# Profiler route only available in dev #} - {% if 'dev' == app.environment %} - +
+
+
+
+
+
+ {{ ux_icon('iconoir:microphone-solid', { height: '150px', width: '150px' }) }} +
+
+
Audio Bot
+

Simple demonstration of speech-to-text with Whisper in combination with GPT.

+ Try Audio Bot +
+ {# Profiler route only available in dev #} + {% if 'dev' == app.environment %} + + {% endif %}
- {% endif %} -
-
-
-
-
- {{ ux_icon('mdi:car-turbocharger', { height: '150px', width: '150px' }) }}
-
-
Turbo Stream Bot
-

Simple demonstration of text streaming capabilities based on Turbo and SSE.

- Try Turbo Stream Bot +
+
+
+ {{ ux_icon('tabler:video-filled', { height: '150px', width: '150px' }) }} +
+
+
Video Bot
+

Simple demonstration of vision capabilities of GPT in combination with your webcam.

+ Try Video Bot +
+ {# Profiler route only available in dev #} + {% if 'dev' == app.environment %} + + {% endif %} +
- {# Profiler route only available in dev #} - {% if 'dev' == app.environment %} -
diff --git a/demo/tests/SmokeTest.php b/demo/tests/SmokeTest.php index 2851dd53b..a753170d4 100644 --- a/demo/tests/SmokeTest.php +++ b/demo/tests/SmokeTest.php @@ -28,7 +28,7 @@ public function testIndex() $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Welcome to the Symfony AI Demo'); - $this->assertSelectorCount(6, '.card'); + $this->assertSelectorCount(7, '.card'); } #[DataProvider('provideChats')]