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 @@
{{ ux_icon('mdi:wikipedia', { height: '20px', width: '20px' }) }} Wikipedia Research
+
+ {{ ux_icon('material-symbols:crop', { height: '20px', width: '20px' }) }} Smart Crop
+
{{ ux_icon('iconoir:microphone-solid', { height: '20px', width: '20px' }) }} Audio
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 @@
+
+
+
+
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 %}
+
+

+
+ {% else %}
+
No image uploaded yet.
+ {% endif %}
+
+
+
+
Cropped Image
+
+ {% if croppedImage %}
+
+

+
+ {% 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')]