diff --git a/test/core/workflow/mocks/image-to-video-body.html b/test/core/workflow/mocks/image-to-video-body.html
new file mode 100644
index 000000000..30f4d0aa3
--- /dev/null
+++ b/test/core/workflow/mocks/image-to-video-body.html
@@ -0,0 +1,26 @@
+
diff --git a/test/core/workflow/workflow.image-to-video.test.js b/test/core/workflow/workflow.image-to-video.test.js
new file mode 100644
index 000000000..7064a8fee
--- /dev/null
+++ b/test/core/workflow/workflow.image-to-video.test.js
@@ -0,0 +1,341 @@
+/* eslint-disable max-len */
+import { expect } from '@esm-bundle/chai';
+import sinon from 'sinon';
+import { readFile } from '@web/test-runner-commands';
+import { setUnityLibs } from '../../../unitylibs/scripts/utils.js';
+import UnityWidget from '../../../unitylibs/core/widgets/image-to-video/image-to-video.js';
+import ActionBinder from '../../../unitylibs/core/workflow/workflow-image-to-video/action-binder.js';
+
+const MODELS_DATA = [
+ { id: 'firefly-v3', version: '3.0', name: 'Firefly v3', icon: '' },
+ { id: 'firefly-v4', version: '4.0', name: 'Firefly v4', icon: '' },
+];
+
+const ASPECT_RATIOS_DATA = [
+ { label: '16:9', value: '16:9', model: 'firefly-v3' },
+ { label: '9:16', value: '9:16', model: 'firefly-v3' },
+ { label: '1:1', value: '1:1', model: 'firefly-v4' },
+];
+
+describe('Image-to-Video Workflow Tests', () => {
+ let unityWidget;
+ let actionBinder;
+ let unityElement;
+ let block;
+ let canvasArea;
+ let workflowCfg;
+ let actionMap;
+ let fetchStub;
+
+ before(async () => {
+ setUnityLibs('', 'unity');
+
+ fetchStub = sinon.stub(window, 'fetch');
+ fetchStub.callsFake(async (url) => {
+ if (url && url.includes('model-picker')) {
+ return { ok: true, json: async () => ({ data: MODELS_DATA }) };
+ }
+ if (url && url.includes('aspect-ratios')) {
+ return { ok: true, json: async () => ({ data: ASPECT_RATIOS_DATA }) };
+ }
+ return { ok: false, status: 404, json: async () => ({}) };
+ });
+
+ document.body.innerHTML = await readFile({ path: './mocks/image-to-video-body.html' });
+
+ unityElement = document.querySelector('.unity');
+ block = document.querySelector('.unity-enabled');
+ canvasArea = document.createElement('div');
+
+ workflowCfg = {
+ name: 'workflow-image-to-video',
+ productName: 'image-to-video',
+ targetCfg: {
+ renderWidget: true,
+ insert: 'before',
+ target: '.row-supplemental',
+ actionMap: {
+ '.gen-btn': [{ actionType: 'generate' }],
+ '.more-filters-btn': [{ actionType: 'moreFilters' }],
+ },
+ },
+ };
+
+ unityWidget = new UnityWidget(block, unityElement, workflowCfg, '');
+ actionMap = await unityWidget.initWidget();
+
+ actionBinder = new ActionBinder(unityElement, workflowCfg, block, canvasArea, actionMap);
+ });
+
+ after(() => {
+ fetchStub.restore();
+ });
+
+ it('should initialize UnityWidget correctly', () => {
+ expect(unityWidget).to.exist;
+ expect(unityWidget.target).to.equal(block);
+ expect(unityWidget.el).to.equal(unityElement);
+ });
+
+ it('should return actionMap from initWidget', () => {
+ expect(actionMap).to.be.an('object');
+ expect(actionMap['.gen-btn']).to.exist;
+ expect(actionMap['.more-filters-btn']).to.exist;
+ });
+
+ it('should insert widget wrap into DOM', () => {
+ expect(document.querySelector('.ex-unity-wrap')).to.exist;
+ expect(document.querySelector('.ex-unity-widget')).to.exist;
+ expect(document.querySelector('.iv-widget')).to.exist;
+ });
+
+ it('should render dropzone with correct elements', () => {
+ const dropzone = document.querySelector('.iv-dropzone');
+ expect(dropzone).to.exist;
+ const fileInput = dropzone.querySelector('.dz-input');
+ expect(fileInput).to.exist;
+ expect(fileInput.type).to.equal('file');
+ expect(fileInput.getAttribute('aria-hidden')).to.equal('true');
+ const loader = dropzone.querySelector('.dz-loader');
+ expect(loader).to.exist;
+ expect(dropzone.classList.contains('loading')).to.be.false;
+ const preview = dropzone.querySelector('.dz-preview');
+ expect(preview).to.exist;
+ expect(dropzone.classList.contains('preview-ready')).to.be.false;
+ });
+
+ it('should render prompt bar with .inp-field textarea', () => {
+ const promptBar = document.querySelector('.iv-prompt-bar');
+ expect(promptBar).to.exist;
+ const textarea = promptBar.querySelector('.inp-field');
+ expect(textarea).to.exist;
+ expect(textarea.tagName.toLowerCase()).to.equal('textarea');
+ });
+
+ it('should render model selector when models are provided', () => {
+ const modelSelector = document.querySelector('.iv-model-selector');
+ expect(modelSelector).to.exist;
+ const selectedBtn = modelSelector.querySelector('.selected-model');
+ expect(selectedBtn).to.exist;
+ const verbList = modelSelector.querySelector('.verb-list');
+ expect(verbList).to.exist;
+ expect(verbList.querySelectorAll('.verb-item').length).to.equal(MODELS_DATA.length);
+ });
+
+ it('should set first model as selected on init', () => {
+ const wrap = document.querySelector('.ex-unity-wrap');
+ expect(wrap.dataset.selectedModelId).to.equal(MODELS_DATA[0].id);
+ expect(wrap.dataset.selectedModelVersion).to.equal(MODELS_DATA[0].version);
+ });
+
+ it('should render aspect ratio options for first model', () => {
+ const arSelector = document.querySelector('.iv-ar-selector');
+ expect(arSelector).to.exist;
+ const firstModelOptions = ASPECT_RATIOS_DATA.filter((ar) => !ar.model || ar.model === MODELS_DATA[0].id);
+ const buttons = arSelector.querySelectorAll('.iv-ar-option');
+ expect(buttons.length).to.equal(firstModelOptions.length);
+ expect(buttons[0].classList.contains('selected')).to.be.true;
+ expect(buttons[0].getAttribute('aria-pressed')).to.equal('true');
+ });
+
+ it('should update aspect ratio options when model is changed', () => {
+ unityWidget.updateAspectRatiosForModel(MODELS_DATA[1].id);
+ const arSelector = document.querySelector('.iv-ar-selector');
+ const secondModelOptions = ASPECT_RATIOS_DATA.filter((ar) => !ar.model || ar.model === MODELS_DATA[1].id);
+ const buttons = arSelector.querySelectorAll('.iv-ar-option');
+ expect(buttons.length).to.equal(secondModelOptions.length);
+ unityWidget.updateAspectRatiosForModel(MODELS_DATA[0].id);
+ });
+
+ it('should render More filters button and generate button', () => {
+ const moreFiltersBtn = document.querySelector('.more-filters-btn');
+ expect(moreFiltersBtn).to.exist;
+ const genBtn = document.querySelector('.gen-btn');
+ expect(genBtn).to.exist;
+ });
+
+ it('should correctly populate placeholders', () => {
+ const placeholders = unityWidget.popPlaceholders();
+ expect(placeholders).to.be.an('object');
+ expect(Object.keys(placeholders).length).to.be.greaterThan(0);
+ expect(placeholders['placeholder-input']).to.equal('Describe your animation');
+ expect(placeholders['placeholder-dropzone']).to.equal('Drag and drop or click to upload');
+ });
+
+ it('should initialize ActionBinder correctly', () => {
+ expect(actionBinder).to.exist;
+ expect(actionBinder.block).to.equal(block);
+ expect(actionBinder.unityEl).to.equal(unityElement);
+ });
+
+ it('should bind event listeners on initActionListeners', async () => {
+ const createErrorToastStub = sinon.stub(actionBinder, 'createErrorToast').resolves(null);
+ await actionBinder.initActionListeners();
+ createErrorToastStub.restore();
+ document.querySelectorAll('.gen-btn').forEach((el) => {
+ expect(el.getAttribute('data-event-bound')).to.equal('true');
+ });
+ document.querySelectorAll('.more-filters-btn').forEach((el) => {
+ expect(el.getAttribute('data-event-bound')).to.equal('true');
+ });
+ });
+
+ it('should call window.open with product URL on moreFilters', () => {
+ const openStub = sinon.stub(window, 'open');
+ actionBinder.moreFilters();
+ expect(openStub.calledOnce).to.be.true;
+ const calledUrl = openStub.firstCall.args[0];
+ expect(calledUrl).to.include('firefly.adobe.com');
+ expect(openStub.firstCall.args[1]).to.equal('_blank');
+ openStub.restore();
+ });
+
+ describe('generateContent', () => {
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it('should build payload without assetId when no image is uploaded', async () => {
+ const wrap = document.querySelector('.ex-unity-wrap');
+ const dropzone = document.querySelector('.iv-dropzone');
+ dropzone.classList.remove('preview-ready');
+ delete wrap._ivUploadedFile;
+
+ const textarea = document.querySelector('.inp-field');
+ textarea.value = 'a calm ocean at sunset';
+
+ let capturedPayload = null;
+ const mockNetworkUtils = {
+ fetchFromService: sandbox.stub().callsFake(async (endpoint, opts, handler) => {
+ capturedPayload = JSON.parse(opts.body);
+ return handler({ status: 200, json: async () => ({ url: '' }) });
+ }),
+ };
+ sandbox.stub(actionBinder, 'getNetworkUtils').resolves(mockNetworkUtils);
+ sandbox.stub(actionBinder, 'createErrorToast').resolves(null);
+
+ await actionBinder.generateContent();
+
+ expect(capturedPayload).to.exist;
+ expect(capturedPayload.assetId).to.be.undefined;
+ expect(capturedPayload.payload.prompt).to.equal('a calm ocean at sunset');
+ expect(capturedPayload.payload.workflow).to.equal('image-to-video');
+ });
+
+ it('should include assetId in payload when image is uploaded', async () => {
+ const wrap = document.querySelector('.ex-unity-wrap');
+ const dropzone = document.querySelector('.iv-dropzone');
+ dropzone.classList.add('preview-ready');
+
+ const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/78fKfoAAAAASUVORK5CYII=';
+ const imageBuffer = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
+ const imageBlob = new Blob([imageBuffer], { type: 'image/png' });
+ const file = new File([imageBlob], 'test.png', { type: 'image/png' });
+ wrap._ivUploadedFile = file;
+
+ sandbox.stub(actionBinder, 'uploadImageIfPresent').resolves('mock-asset-id-123');
+
+ let capturedPayload = null;
+ const mockNetworkUtils = {
+ fetchFromService: sandbox.stub().callsFake(async (endpoint, opts, handler) => {
+ capturedPayload = JSON.parse(opts.body);
+ return handler({ status: 200, json: async () => ({ url: '' }) });
+ }),
+ };
+ sandbox.stub(actionBinder, 'getNetworkUtils').resolves(mockNetworkUtils);
+ sandbox.stub(actionBinder, 'createErrorToast').resolves(null);
+
+ await actionBinder.generateContent();
+
+ expect(capturedPayload).to.exist;
+ expect(capturedPayload.assetId).to.equal('mock-asset-id-123');
+
+ dropzone.classList.remove('preview-ready');
+ delete wrap._ivUploadedFile;
+ });
+
+ it('should navigate to returned URL on successful generate', async () => {
+ const dropzone = document.querySelector('.iv-dropzone');
+ dropzone.classList.remove('preview-ready');
+
+ const redirectUrl = 'https://firefly.adobe.com/video/result';
+ let fetchedUrl = null;
+ const mockNetworkUtils = {
+ fetchFromService: sandbox.stub().callsFake(async (endpoint, opts, handler) => {
+ fetchedUrl = endpoint;
+ return handler({
+ status: 200,
+ json: async () => ({ url: redirectUrl }),
+ });
+ }),
+ };
+ sandbox.stub(actionBinder, 'getNetworkUtils').resolves(mockNetworkUtils);
+ sandbox.stub(actionBinder, 'createErrorToast').resolves(null);
+
+ await actionBinder.generateContent();
+
+ expect(fetchedUrl).to.exist;
+ expect(mockNetworkUtils.fetchFromService.calledOnce).to.be.true;
+ });
+
+ it('should show error toast on generate failure', async () => {
+ const dropzone = document.querySelector('.iv-dropzone');
+ dropzone.classList.remove('preview-ready');
+
+ const mockNetworkUtils = {
+ fetchFromService: sandbox.stub().rejects(new Error('Network error')),
+ };
+ sandbox.stub(actionBinder, 'getNetworkUtils').resolves(mockNetworkUtils);
+ const showErrorToastStub = sandbox.stub(actionBinder, 'showErrorToast').resolves();
+
+ await actionBinder.generateContent();
+
+ expect(showErrorToastStub.calledOnce).to.be.true;
+ });
+ });
+
+ describe('handleKeyDown', () => {
+ it('should trigger gen-btn click on Enter in inp-field', () => {
+ const textarea = document.querySelector('.inp-field');
+ const genBtn = document.querySelector('.gen-btn');
+ const clickStub = sinon.stub(genBtn, 'click');
+
+ const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
+ Object.defineProperty(enterEvent, 'target', { value: textarea, writable: false });
+ actionBinder.handleKeyDown(enterEvent);
+
+ expect(clickStub.calledOnce).to.be.true;
+ clickStub.restore();
+ });
+
+ it('should ignore non-valid keys', () => {
+ const genBtn = document.querySelector('.gen-btn');
+ const clickStub = sinon.stub(genBtn, 'click');
+
+ const event = new KeyboardEvent('keydown', { key: 'a', bubbles: true });
+ actionBinder.handleKeyDown(event);
+
+ expect(clickStub.called).to.be.false;
+ clickStub.restore();
+ });
+ });
+
+ describe('execActions', () => {
+ it('should handle unknown action types gracefully', async () => {
+ const invalidAction = { actionType: 'unknownAction' };
+ let threw = false;
+ try {
+ await actionBinder.execActions([invalidAction]);
+ } catch (e) {
+ threw = true;
+ }
+ expect(threw).to.be.false;
+ });
+ });
+});
diff --git a/unitylibs/core/widgets/image-to-video/image-to-video.css b/unitylibs/core/widgets/image-to-video/image-to-video.css
new file mode 100644
index 000000000..91228a737
--- /dev/null
+++ b/unitylibs/core/widgets/image-to-video/image-to-video.css
@@ -0,0 +1,414 @@
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap {
+ position: relative;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 0 var(--spacing-l);
+ animation: iv-fade-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
+ position: relative;
+ z-index: 1;
+}
+
+@keyframes iv-fade-in {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone {
+ min-height: 200px;
+ border: 2px dashed #ccc;
+ border-radius: 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ cursor: pointer;
+ overflow: hidden;
+ background: #fafafa;
+ transition: border-color 0.2s ease, background 0.2s ease;
+ gap: 8px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone:hover {
+ border-color: #999;
+ background: #f0f0f0;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone.drag-over {
+ border-color: #1473E6;
+ background: #EBF5FF;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone .dz-input {
+ display: none;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone .dz-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #6e6e6e;
+ width: 32px;
+ height: 32px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone.preview-ready .dz-icon,
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone.loading .dz-icon {
+ display: none;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone .dz-text {
+ font-size: var(--type-body-s-size, 14px);
+ color: #6e6e6e;
+ text-align: center;
+ padding: 0 16px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone.preview-ready .dz-text,
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone.loading .dz-text {
+ display: none;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone .dz-loader {
+ display: none;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone.loading .dz-loader {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ inset: 0;
+ background: rgba(255 255 255 / 70%);
+ z-index: 2;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone .dz-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid #e0e0e0;
+ border-top-color: #1473E6;
+ border-radius: 50%;
+ animation: iv-spin 0.8s linear infinite;
+}
+
+@keyframes iv-spin {
+ to { transform: rotate(360deg); }
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone .dz-preview {
+ display: none;
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 10px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone.preview-ready .dz-preview {
+ display: block;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-prompt-bar {
+ margin-top: 8px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-prompt-bar .inp-field {
+ width: 100%;
+ min-height: 80px;
+ padding: 12px;
+ border: 1px solid #ccc;
+ border-radius: 8px;
+ font-size: var(--type-body-s-size, 16px);
+ font-family: inherit;
+ resize: vertical;
+ box-sizing: border-box;
+ outline: none;
+ transition: border-color 0.2s ease;
+ background: transparent;
+ color: inherit;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-prompt-bar .inp-field:focus {
+ border-color: #1473E6;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-prompt-bar .inp-field::placeholder {
+ font-style: italic;
+ color: #767676;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-selectors-row {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-top: 8px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector {
+ position: relative;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .selected-model {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 7px 12px;
+ cursor: pointer;
+ border: 1px solid #ccc;
+ border-radius: 20px;
+ font-size: 14px;
+ font-family: inherit;
+ font-weight: 400;
+ background: transparent;
+ color: inherit;
+ min-width: 88px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .selected-model img {
+ width: 22px;
+ height: 22px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .menu-icon {
+ position: relative;
+ top: 1px;
+ transition: transform 0.15s ease-in;
+ width: 12px;
+ height: 12px;
+ font-size: 0;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector.show-menu .menu-icon {
+ transform: rotate(-180deg);
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .verb-list {
+ padding: 18px;
+ list-style: none;
+ box-shadow: 0 0 10px #0000001c;
+ border-radius: 10px;
+ background: #fff;
+ color: #292929;
+ margin: 0;
+ min-width: 160px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 10;
+ animation: iv-move-down 0.4s cubic-bezier(0.5, 1.8, 0.3, 0.8) forwards;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .verb-item {
+ list-style: none;
+ position: relative;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .verb-link {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 10px 10px 25px;
+ text-decoration: none;
+ color: inherit;
+ font-size: 14px;
+ position: relative;
+ opacity: 0;
+ animation: iv-link-fade-in 0.5s ease forwards;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .verb-link img {
+ width: 22px;
+ height: 22px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .selected-icon {
+ display: none;
+ position: absolute;
+ top: 50%;
+ left: 3px;
+ transform: translateY(-50%);
+ width: 12px;
+ height: 12px;
+ font-size: 0;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-model-selector .verb-item.selected .selected-icon {
+ display: block;
+}
+
+@keyframes iv-move-down {
+ 0% { transform: translateY(33px); opacity: 0; }
+ 100% { transform: translateY(40px); opacity: 1; }
+}
+
+@keyframes iv-link-fade-in {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-ar-selector {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-ar-selector .iv-ar-option {
+ padding: 6px 14px;
+ border: 1px solid #ccc;
+ border-radius: 20px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 14px;
+ font-family: inherit;
+ color: inherit;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-ar-selector .iv-ar-option:hover {
+ border-color: #1473E6;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-ar-selector .iv-ar-option.selected {
+ border-color: #1473E6;
+ background: #EBF5FF;
+ font-weight: 600;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-action-row {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ align-items: center;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-action-row .more-filters-btn {
+ padding: 8px 16px;
+ border: 1px solid #6E6E6E;
+ border-radius: 20px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 14px;
+ text-decoration: none;
+ color: inherit;
+ font-family: inherit;
+ display: inline-flex;
+ align-items: center;
+ transition: border-color 0.15s ease;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-action-row .more-filters-btn:hover {
+ border-color: #1473E6;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-action-row .gen-btn {
+ padding: 8px 20px;
+ border-radius: 20px;
+ background: linear-gradient(90deg, #D73220 0%, #D92361 33%, #7155FA 100%);
+ color: #fff;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ font-family: inherit;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ transition: opacity 0.15s ease;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-action-row .gen-btn:hover {
+ opacity: 0.9;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 100;
+ min-width: 300px;
+ display: none;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder.show {
+ display: block;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder .alert-toast {
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 4px 24px rgba(0 0 0 / 18%);
+ padding: 16px 20px;
+ max-width: 360px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder .alert-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder .alert-icon {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ flex: 1;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder .alert-text {
+ flex: 1;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder .alert-text p {
+ margin: 0;
+ font-size: 14px;
+ color: #292929;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder .alert-close {
+ cursor: pointer;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ color: inherit;
+}
+
+.upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .alert-holder .alert-close-text {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (max-width: 1024px) {
+ .upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget {
+ padding: 0;
+ }
+}
+
+@media (max-width: 599px) {
+ .upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget {
+ padding: 0;
+ }
+
+ .upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-dropzone {
+ min-height: 160px;
+ }
+
+ .upload-marquee.unity-enabled .interactive-area .ex-unity-wrap .iv-widget .iv-action-row .gen-btn .btn-txt {
+ display: none;
+ }
+}
diff --git a/unitylibs/core/widgets/image-to-video/image-to-video.js b/unitylibs/core/widgets/image-to-video/image-to-video.js
new file mode 100644
index 000000000..914fa4f9a
--- /dev/null
+++ b/unitylibs/core/widgets/image-to-video/image-to-video.js
@@ -0,0 +1,384 @@
+import { createTag } from '../../../scripts/utils.js';
+
+export default class UnityWidget {
+ constructor(target, el, workflowCfg, spriteCon) {
+ this.el = el;
+ this.target = target;
+ this.workflowCfg = workflowCfg;
+ this.spriteCon = spriteCon;
+ this.widget = null;
+ this.widgetWrap = null;
+ this.actionMap = {};
+ this.models = null;
+ this.aspectRatios = null;
+ this.selectedModel = '';
+ this.selectedModelVersion = '';
+ this.selectedAspectRatio = '';
+ this.uploadedFile = null;
+ this.lanaOptions = { sampleRate: 100, tags: 'Unity-IV' };
+ }
+
+ async initWidget() {
+ const widgetWrap = createTag('div', { class: 'ex-unity-wrap' });
+ const widget = createTag('div', { class: 'ex-unity-widget iv-widget' });
+ this.widgetWrap = widgetWrap;
+ this.widget = widget;
+
+ this.workflowCfg.placeholder = this.popPlaceholders();
+ const ph = this.workflowCfg.placeholder;
+
+ const hasModels = !!this.el.querySelector('[class*="icon-model"]');
+ const hasAspectRatios = !!this.el.querySelector('.icon-aspect-ratio');
+
+ if (hasModels) await this.loadModels();
+ if (hasAspectRatios) await this.loadAspectRatios();
+
+ const dropzone = this.buildDropzone(ph);
+ const promptBar = this.buildPromptBar(ph);
+
+ const selectorsRow = createTag('div', { class: 'iv-selectors-row' });
+ if (this.models && this.models.length > 0) {
+ const modelSelector = this.buildModelSelector();
+ selectorsRow.append(modelSelector);
+ }
+ if (this.aspectRatios && this.aspectRatios.length > 0) {
+ const firstModelId = this.selectedModel || (this.models?.[0]?.id ?? '');
+ const arSelector = this.buildAspectRatioSelector(firstModelId);
+ selectorsRow.append(arSelector);
+ }
+
+ const actionRow = this.buildActionRow(ph);
+
+ widget.append(dropzone, promptBar, selectorsRow, actionRow);
+ widgetWrap.append(widget);
+
+ this.addWidget();
+
+ return this.workflowCfg.targetCfg.actionMap;
+ }
+
+ popPlaceholders() {
+ return Object.fromEntries(
+ [...this.el.querySelectorAll('[class*="placeholder"]')].map((element) => [
+ element.classList[1]?.replace('icon-', '') || '',
+ element.closest('li')?.innerText || '',
+ ]).filter(([key]) => key),
+ );
+ }
+
+ async loadModels() {
+ try {
+ const { origin } = window.location;
+ const baseUrl = (origin.includes('.aem.') || origin.includes('.hlx.'))
+ ? `https://main--unity--adobecom.${origin.includes('.hlx.') ? 'hlx' : 'aem'}.live`
+ : origin;
+ const modelEl = this.el.querySelector('[class*="icon-model"]');
+ const modelHref = modelEl?.querySelector('a')?.href;
+ const modelUrl = modelHref || `${baseUrl}/unity/configs/prompt/model-picker.json`;
+ const res = await fetch(modelUrl);
+ if (!res.ok) throw new Error('Failed to fetch models.');
+ const json = await res.json();
+ this.models = json?.content?.data || json?.data || [];
+ } catch (e) {
+ window.lana?.log(`Message: Error loading models, Error: ${e}`, this.lanaOptions);
+ this.models = [];
+ }
+ }
+
+ async loadAspectRatios() {
+ try {
+ const arEl = this.el.querySelector('.icon-aspect-ratio');
+ const arHref = arEl?.querySelector('a')?.href || arEl?.closest('li')?.querySelector('a')?.href;
+ if (!arHref) {
+ this.aspectRatios = [];
+ return;
+ }
+ const res = await fetch(arHref);
+ if (!res.ok) throw new Error('Failed to fetch aspect ratios.');
+ const json = await res.json();
+ this.aspectRatios = json?.content?.data || json?.data || [];
+ } catch (e) {
+ window.lana?.log(`Message: Error loading aspect ratios, Error: ${e}`, this.lanaOptions);
+ this.aspectRatios = [];
+ }
+ }
+
+ buildDropzone(ph) {
+ const dropzone = createTag('div', { class: 'iv-dropzone', role: 'button', tabindex: '0', 'aria-label': ph['placeholder-dropzone'] || 'Upload image' });
+ const input = createTag('input', { type: 'file', class: 'dz-input', accept: 'image/*', 'aria-hidden': 'true', tabindex: '-1' });
+ const uploadIcon = createTag('div', { class: 'dz-icon' }, ``);
+ const placeholderText = createTag('span', { class: 'dz-text' }, ph['placeholder-dropzone'] || 'Drag and drop or click to upload');
+ const loader = createTag('div', { class: 'dz-loader' }, '');
+ const preview = createTag('img', { class: 'dz-preview', alt: '' });
+
+ dropzone.append(input, uploadIcon, placeholderText, loader, preview);
+
+ const processFile = (file) => {
+ if (!file || !file.type.startsWith('image/')) return;
+ this.uploadedFile = file;
+ if (this.widgetWrap) this.widgetWrap._ivUploadedFile = file;
+ dropzone.classList.add('loading');
+ dropzone.classList.remove('drag-over', 'preview-ready');
+ const url = URL.createObjectURL(file);
+ preview.onload = () => {
+ dropzone.classList.remove('loading');
+ dropzone.classList.add('preview-ready');
+ preview.src = url;
+ };
+ preview.src = url;
+ };
+
+ dropzone.addEventListener('click', (e) => {
+ if (e.target === input) return;
+ input.click();
+ });
+
+ dropzone.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ input.click();
+ }
+ });
+
+ input.addEventListener('change', (e) => {
+ const file = e.target.files?.[0];
+ if (file) processFile(file);
+ e.target.value = '';
+ });
+
+ dropzone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dropzone.classList.add('drag-over');
+ });
+
+ dropzone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dropzone.classList.remove('drag-over');
+ });
+
+ dropzone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dropzone.classList.remove('drag-over');
+ const file = e.dataTransfer?.files?.[0];
+ if (file) processFile(file);
+ });
+
+ return dropzone;
+ }
+
+ buildPromptBar(ph) {
+ const promptBar = createTag('div', { class: 'iv-prompt-bar' });
+ const textarea = createTag('textarea', {
+ class: 'inp-field',
+ placeholder: ph['placeholder-input'] || 'Describe your animation',
+ 'aria-label': ph['placeholder-input'] || 'Describe your animation',
+ rows: '3',
+ });
+ promptBar.append(textarea);
+ return promptBar;
+ }
+
+ buildModelSelector() {
+ if (!this.models || this.models.length === 0) return createTag('div', { class: 'iv-model-selector' });
+
+ const firstModel = this.models[0];
+ this.selectedModel = firstModel.id || '';
+ this.selectedModelVersion = firstModel.version || '';
+ this.widgetWrap.dataset.selectedModelId = this.selectedModel;
+ this.widgetWrap.dataset.selectedModelVersion = this.selectedModelVersion;
+
+ const container = createTag('div', { class: 'iv-model-selector models-container', 'aria-label': 'Model options' });
+ const nameSpan = createTag('span', { class: 'model-name' }, firstModel.name?.trim() || '');
+ const menuIcon = createTag('span', { class: 'menu-icon' }, '');
+ const selectedBtn = createTag('button', {
+ class: 'selected-model',
+ 'aria-expanded': 'false',
+ 'aria-controls': 'iv-model-menu',
+ 'aria-label': 'model type',
+ 'aria-haspopup': 'listbox',
+ role: 'combobox',
+ 'data-selected-model-id': this.selectedModel,
+ 'data-selected-model-version': this.selectedModelVersion,
+ });
+ if (firstModel.icon) selectedBtn.append(createTag('img', { src: firstModel.icon, alt: '', loading: 'lazy', width: '22', height: '22' }));
+ selectedBtn.append(nameSpan, menuIcon);
+
+ const list = createTag('ul', {
+ class: 'verb-list',
+ id: 'iv-model-menu',
+ role: 'listbox',
+ 'aria-labelledby': 'iv-model-menu',
+ style: 'display:none',
+ });
+
+ this.models.forEach((model, idx) => {
+ const li = createTag('li', { class: `verb-item${idx === 0 ? ' selected' : ''}`, role: 'presentation' });
+ const selectedIcon = createTag('span', { class: 'selected-icon' }, '');
+ const modelName = createTag('span', { class: 'model-name' }, model.name?.trim() || '');
+ const link = createTag('a', {
+ href: '#',
+ class: 'verb-link model-link',
+ 'data-model-id': model.id || '',
+ 'data-model-version': model.version || '',
+ 'aria-selected': idx === 0 ? 'true' : 'false',
+ role: 'option',
+ });
+ if (model.icon) link.append(createTag('img', { src: model.icon, alt: '', loading: 'lazy', width: '22', height: '22' }));
+ link.append(selectedIcon, modelName);
+ li.append(link);
+ list.append(li);
+ });
+
+ const handleDocumentClick = (e) => {
+ if (!container.contains(e.target)) {
+ document.removeEventListener('click', handleDocumentClick);
+ container.classList.remove('show-menu');
+ selectedBtn.setAttribute('aria-expanded', 'false');
+ }
+ };
+
+ selectedBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const isOpen = container.classList.toggle('show-menu');
+ selectedBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
+ if (isOpen) document.addEventListener('click', handleDocumentClick);
+ else document.removeEventListener('click', handleDocumentClick);
+ });
+
+ selectedBtn.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ selectedBtn.click();
+ }
+ if (e.key === 'Escape') {
+ container.classList.remove('show-menu');
+ selectedBtn.setAttribute('aria-expanded', 'false');
+ selectedBtn.focus();
+ }
+ });
+
+ list.addEventListener('click', (e) => {
+ const link = e.target.closest('.verb-link');
+ if (!link) return;
+ e.preventDefault();
+ e.stopPropagation();
+
+ list.querySelectorAll('.verb-item').forEach((item) => {
+ item.classList.remove('selected');
+ item.querySelector('.verb-link')?.setAttribute('aria-selected', 'false');
+ });
+ link.closest('.verb-item').classList.add('selected');
+ link.setAttribute('aria-selected', 'true');
+
+ const modelId = link.dataset.modelId;
+ const modelVersion = link.dataset.modelVersion;
+ const modelObj = this.models.find((m) => m.id === modelId);
+
+ this.selectedModel = modelId;
+ this.selectedModelVersion = modelVersion;
+ this.widgetWrap.dataset.selectedModelId = modelId;
+ this.widgetWrap.dataset.selectedModelVersion = modelVersion;
+
+ selectedBtn.dataset.selectedModelId = modelId;
+ selectedBtn.dataset.selectedModelVersion = modelVersion;
+
+ const newName = createTag('span', { class: 'model-name' }, modelObj?.name?.trim() || '');
+ selectedBtn.innerHTML = '';
+ if (modelObj?.icon) selectedBtn.append(createTag('img', { src: modelObj.icon, alt: '', loading: 'lazy', width: '22', height: '22' }));
+ selectedBtn.append(newName, menuIcon);
+
+ container.classList.remove('show-menu');
+ selectedBtn.setAttribute('aria-expanded', 'false');
+ selectedBtn.focus();
+
+ this.updateAspectRatiosForModel(modelId);
+ });
+
+ container.append(selectedBtn, list);
+ return container;
+ }
+
+ buildAspectRatioSelector(modelId) {
+ const container = createTag('div', { class: 'iv-ar-selector' });
+ const options = this.getAspectRatiosForModel(modelId);
+ if (options.length > 0) {
+ this.selectedAspectRatio = options[0].value || options[0].label || '';
+ this.widgetWrap.dataset.selectedAspectRatio = this.selectedAspectRatio;
+ }
+ this.renderAspectRatioOptions(container, options);
+ return container;
+ }
+
+ getAspectRatiosForModel(modelId) {
+ if (!this.aspectRatios || this.aspectRatios.length === 0) return [];
+ return this.aspectRatios.filter((ar) => !ar.model || ar.model === modelId);
+ }
+
+ renderAspectRatioOptions(container, options) {
+ container.innerHTML = '';
+ options.forEach((ar, idx) => {
+ const value = ar.value || ar.label || '';
+ const label = ar.label || ar.value || '';
+ const btn = createTag('button', {
+ class: `iv-ar-option${idx === 0 ? ' selected' : ''}`,
+ 'data-value': value,
+ 'aria-pressed': idx === 0 ? 'true' : 'false',
+ type: 'button',
+ }, label);
+ btn.addEventListener('click', () => {
+ container.querySelectorAll('.iv-ar-option').forEach((b) => {
+ b.classList.remove('selected');
+ b.setAttribute('aria-pressed', 'false');
+ });
+ btn.classList.add('selected');
+ btn.setAttribute('aria-pressed', 'true');
+ this.selectedAspectRatio = value;
+ this.widgetWrap.dataset.selectedAspectRatio = value;
+ });
+ container.append(btn);
+ });
+ }
+
+ updateAspectRatiosForModel(modelId) {
+ const arSelector = this.widget.querySelector('.iv-ar-selector');
+ if (!arSelector) return;
+ const options = this.getAspectRatiosForModel(modelId);
+ if (options.length > 0) {
+ this.selectedAspectRatio = options[0].value || options[0].label || '';
+ this.widgetWrap.dataset.selectedAspectRatio = this.selectedAspectRatio;
+ } else {
+ this.selectedAspectRatio = '';
+ delete this.widgetWrap.dataset.selectedAspectRatio;
+ }
+ this.renderAspectRatioOptions(arSelector, options);
+ }
+
+ buildActionRow(ph) {
+ const row = createTag('div', { class: 'iv-action-row' });
+
+ const moreFiltersBtn = createTag('button', {
+ class: 'more-filters-btn',
+ type: 'button',
+ }, ph['placeholder-more-filters'] || ph['more-filters'] || 'More filters');
+
+ const generateBtn = createTag('a', {
+ href: '#',
+ class: 'gen-btn unity-act-btn',
+ }, ph['placeholder-generate'] || ph['generate'] || 'Generate');
+
+ row.append(moreFiltersBtn, generateBtn);
+ return row;
+ }
+
+ addWidget() {
+ const targetCfg = this.workflowCfg.targetCfg;
+ const interactArea = this.target.querySelector('.copy') || this.target;
+ const para = interactArea?.querySelector(targetCfg.target);
+ if (para && targetCfg.insert === 'before') para.before(this.widgetWrap);
+ else if (para && targetCfg.insert === 'after') para.after(this.widgetWrap);
+ else interactArea?.appendChild(this.widgetWrap);
+ }
+}
diff --git a/unitylibs/core/workflow/workflow-image-to-video/action-binder.js b/unitylibs/core/workflow/workflow-image-to-video/action-binder.js
new file mode 100644
index 000000000..3413bc479
--- /dev/null
+++ b/unitylibs/core/workflow/workflow-image-to-video/action-binder.js
@@ -0,0 +1,303 @@
+/* eslint-disable max-len */
+/* eslint-disable class-methods-use-this */
+/* eslint-disable no-await-in-loop */
+
+import {
+ unityConfig,
+ getUnityLibs,
+ createTag,
+ getLibs,
+ getApiCallOptions,
+ getLocale,
+ sendAnalyticsEvent,
+ getHeaders,
+} from '../../../scripts/utils.js';
+
+export default class ActionBinder {
+ static VALID_KEYS = ['Tab', 'ArrowDown', 'ArrowUp', 'Enter', 'Escape', ' '];
+
+ boundHandleKeyDown = this.handleKeyDown.bind(this);
+
+ constructor(unityEl, workflowCfg, block, canvasArea, actionMap = {}) {
+ this.unityEl = unityEl;
+ this.workflowCfg = workflowCfg;
+ this.block = block;
+ this.canvasArea = canvasArea;
+ this.actions = actionMap;
+ this.apiConfig = { ...unityConfig };
+ this.apiConfig.endPoint = {
+ assetUpload: `${unityConfig.apiEndPoint}/asset`,
+ };
+ this.errorToastEl = null;
+ this.lanaOptions = { sampleRate: 100, tags: 'Unity-IV' };
+ this.widgetWrap = this.block?.querySelector('.ex-unity-wrap');
+ this.networkUtils = null;
+ this.addKeyDown();
+ }
+
+ getNetworkUtils = async () => {
+ if (this.networkUtils) return this.networkUtils;
+ const { default: NetworkUtils } = await import(`${getUnityLibs()}/utils/NetworkUtils.js`);
+ return (this.networkUtils = new NetworkUtils());
+ };
+
+ addKeyDown() {
+ this.block?.addEventListener('keydown', this.boundHandleKeyDown);
+ }
+
+ handleKeyDown(ev) {
+ if (!ActionBinder.VALID_KEYS.includes(ev.key)) return;
+ const target = ev.target;
+ if (ev.key === ' ' && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT')) return;
+ if (ev.key === 'Enter' && target.classList.contains('inp-field')) {
+ ev.preventDefault();
+ this.block?.querySelector('.gen-btn')?.click();
+ }
+ }
+
+ async initActionListeners() {
+ Object.entries(this.actions).forEach(([selector, actionsList]) => {
+ this.block?.querySelectorAll(selector).forEach((el) => {
+ if (!el.hasAttribute('data-event-bound')) {
+ this.addEventListeners(el, actionsList);
+ el.setAttribute('data-event-bound', 'true');
+ }
+ });
+ });
+ if (!this.errorToastEl) this.errorToastEl = await this.createErrorToast();
+ }
+
+ addEventListeners(el, actionsList) {
+ const handleClick = async (event) => {
+ event.preventDefault();
+ await this.execActions(actionsList, el);
+ };
+ switch (el.nodeName) {
+ case 'A':
+ case 'BUTTON':
+ el.addEventListener('click', handleClick);
+ break;
+ case 'TEXTAREA':
+ el.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.block?.querySelector('.gen-btn')?.click();
+ }
+ });
+ break;
+ default:
+ break;
+ }
+ }
+
+ async execActions(actionsList, el = null) {
+ try {
+ const list = Array.isArray(actionsList) ? actionsList : [actionsList];
+ for (const action of list) {
+ await this.handleAction(action, el);
+ }
+ } catch (err) {
+ window.lana?.log(`Message: Actions failed, Error: ${err}`, this.lanaOptions);
+ }
+ }
+
+ async handleAction(action, el) {
+ const actionType = typeof action === 'string' ? action : action.actionType;
+ const actionMap = {
+ generate: () => this.generateContent(),
+ moreFilters: () => this.moreFilters(),
+ };
+ const execute = actionMap[actionType];
+ if (execute) await execute();
+ }
+
+ moreFilters() {
+ const productUrlEl = this.unityEl?.querySelector('.icon-product-url');
+ const url = productUrlEl?.querySelector('a')?.href
+ || productUrlEl?.closest('li')?.querySelector('a')?.href
+ || productUrlEl?.closest('li')?.textContent?.trim()
+ || 'https://firefly.adobe.com';
+ window.open(url, '_blank', 'noopener,noreferrer');
+ }
+
+ getUploadedFile() {
+ const dropzone = this.block?.querySelector('.iv-dropzone');
+ if (!dropzone) return null;
+ const widget = dropzone.closest('.ex-unity-wrap');
+ if (!widget) return null;
+ const widgetInstance = widget._ivWidgetInstance;
+ if (widgetInstance) return widgetInstance.uploadedFile;
+ return null;
+ }
+
+ async uploadImageIfPresent() {
+ const dropzone = this.block?.querySelector('.iv-dropzone');
+ if (!dropzone || !dropzone.classList.contains('preview-ready')) return null;
+
+ const wrap = this.block?.querySelector('.ex-unity-wrap');
+ if (!wrap || !wrap._ivUploadedFile) return null;
+
+ const file = wrap._ivUploadedFile;
+
+ try {
+ const assetDetails = {
+ targetProduct: this.workflowCfg.productName,
+ name: file.name,
+ size: file.size,
+ format: file.type,
+ };
+ const postOpts = {
+ method: 'POST',
+ headers: await getHeaders(unityConfig.apiKey, {
+ 'x-unity-product': this.workflowCfg.productName,
+ 'x-unity-action': 'image-to-video-upload',
+ }),
+ body: JSON.stringify(assetDetails),
+ };
+ const res = await fetch(this.apiConfig.endPoint.assetUpload, postOpts);
+ if (!res.ok) {
+ const err = new Error('Asset upload initiation failed');
+ err.status = res.status;
+ throw err;
+ }
+ const { id, href } = await res.json();
+ if (href) {
+ const uploadOpts = {
+ method: 'PUT',
+ headers: { 'Content-Type': file.type },
+ body: file,
+ };
+ const uploadRes = await fetch(href, uploadOpts);
+ if (!uploadRes.ok) {
+ const err = new Error('Image upload to storage failed');
+ err.status = uploadRes.status;
+ throw err;
+ }
+ }
+ return id || null;
+ } catch (e) {
+ window.lana?.log(`Message: Image upload failed, Error: ${e}`, this.lanaOptions);
+ return null;
+ }
+ }
+
+ async generateContent() {
+ const promptEl = this.block?.querySelector('.inp-field');
+ const prompt = promptEl?.value?.trim() || '';
+ const wrap = this.block?.querySelector('.ex-unity-wrap');
+ const modelId = wrap?.dataset?.selectedModelId || '';
+ const modelVersion = wrap?.dataset?.selectedModelVersion || '';
+ const aspectRatio = wrap?.dataset?.selectedAspectRatio || '';
+
+ const { getCgenQueryParams } = await import(`${getUnityLibs()}/utils/cgen-utils.js`);
+ const queryParams = getCgenQueryParams(this.unityEl);
+
+ let assetId = null;
+ const dropzone = this.block?.querySelector('.iv-dropzone');
+ if (dropzone?.classList.contains('preview-ready') && wrap?._ivUploadedFile) {
+ assetId = await this.uploadImageIfPresent();
+ }
+
+ try {
+ const payload = {
+ targetProduct: this.workflowCfg.productName,
+ additionalQueryParams: queryParams,
+ payload: {
+ workflow: 'image-to-video',
+ locale: getLocale(),
+ action: 'generate',
+ ...(modelId ? { modelId } : {}),
+ ...(modelVersion ? { modelVersion } : {}),
+ ...(aspectRatio ? { aspectRatio } : {}),
+ ...(prompt ? { prompt } : {}),
+ },
+ ...(assetId ? { assetId } : {}),
+ };
+
+ const postOpts = await getApiCallOptions(
+ 'POST',
+ unityConfig.apiKey,
+ {
+ 'x-unity-product': this.workflowCfg.productName,
+ 'x-unity-action': 'generate-image-to-video',
+ },
+ { body: JSON.stringify(payload) },
+ );
+
+ const networkUtils = await this.getNetworkUtils();
+ const { url } = await networkUtils.fetchFromService(
+ this.apiConfig.connectorApiEndPoint,
+ postOpts,
+ async (response) => {
+ if (response.status !== 200) {
+ const error = new Error();
+ error.status = response.status;
+ throw error;
+ }
+ return response.json();
+ },
+ );
+ if (url) window.location.href = url;
+ } catch (err) {
+ await this.showErrorToast(err);
+ window.lana?.log(`Message: Image-to-video generation failed, Error: ${err}`, this.lanaOptions);
+ }
+ }
+
+ async showErrorToast(err) {
+ if (!this.errorToastEl) this.errorToastEl = await this.createErrorToast();
+ if (!this.errorToastEl) return;
+ const ivWidget = this.block?.querySelector('.iv-widget');
+ if (!ivWidget) return;
+ ivWidget.style.pointerEvents = 'none';
+ const lang = document.querySelector('html')?.getAttribute('lang');
+ const errorEl = this.unityEl?.querySelector('.icon-error-request');
+ const msg = lang !== 'ja-JP'
+ ? errorEl?.nextSibling?.textContent
+ : errorEl?.parentElement?.textContent;
+ const alertText = this.errorToastEl.querySelector('.alert-text p');
+ if (alertText) alertText.innerText = msg || 'Something went wrong. Please try again.';
+ this.errorToastEl.classList.add('show');
+ sendAnalyticsEvent(new CustomEvent('IV Generate error|UnityWidget'));
+ window.lana?.log(`Message: ${msg || 'Generation error'}, Error: ${err || ''}`, this.lanaOptions);
+ }
+
+ async createErrorToast() {
+ try {
+ const { decorateDefaultLinkAnalytics } = await import(`${getLibs()}/martech/attributes.js`);
+ const ivWidget = this.block?.querySelector('.iv-widget');
+ if (!ivWidget) return null;
+
+ const alertImg = createTag('img', { loading: 'lazy', src: `${getUnityLibs()}/img/icons/alert.svg` });
+ const closeImg = createTag('img', { loading: 'lazy', src: `${getUnityLibs()}/img/icons/close.svg` });
+
+ const alertText = createTag('div', { class: 'alert-text' }, createTag('p', {}, 'Alert Text'));
+ const alertIcon = createTag('div', { class: 'alert-icon' });
+ alertIcon.append(alertImg, alertText);
+ const alertClose = createTag('a', { class: 'alert-close', href: '#' });
+ alertClose.append(closeImg, createTag('span', { class: 'alert-close-text' }, 'Close error toast'));
+ const alertContent = createTag('div', { class: 'alert-content' });
+ alertContent.append(alertIcon, alertClose);
+ const alertToast = createTag('div', { class: 'alert-toast' }, alertContent);
+ const errholder = createTag('div', { class: 'alert-holder' }, alertToast);
+
+ const closeToast = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ errholder.classList.remove('show');
+ ivWidget.style.pointerEvents = 'auto';
+ };
+ alertClose.addEventListener('click', closeToast);
+ alertClose.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') closeToast(e);
+ });
+
+ decorateDefaultLinkAnalytics(errholder);
+ ivWidget.append(errholder);
+ return errholder;
+ } catch (e) {
+ window.lana?.log(`Message: Error creating error toast, Error: ${e}`, this.lanaOptions);
+ return null;
+ }
+ }
+}
diff --git a/unitylibs/core/workflow/workflow-image-to-video/target-config.json b/unitylibs/core/workflow/workflow-image-to-video/target-config.json
new file mode 100644
index 000000000..4284a9668
--- /dev/null
+++ b/unitylibs/core/workflow/workflow-image-to-video/target-config.json
@@ -0,0 +1,16 @@
+{
+ "_defaults": {
+ "renderWidget": true,
+ "sendSplunkAnalytics": true,
+ "actionMap": {
+ ".gen-btn": [{ "actionType": "generate" }],
+ ".more-filters-btn": [{ "actionType": "moreFilters" }]
+ }
+ },
+ "upload-marquee": {
+ "selector": ".copy",
+ "source": ".copy",
+ "target": ".row-supplemental",
+ "insert": "before"
+ }
+}
diff --git a/unitylibs/core/workflow/workflow.js b/unitylibs/core/workflow/workflow.js
index 1a726663d..3cc79b5ce 100644
--- a/unitylibs/core/workflow/workflow.js
+++ b/unitylibs/core/workflow/workflow.js
@@ -34,6 +34,10 @@ class WfInitiator {
`${widgetBase}/prompt-bar-style/prompt-bar-style.js`,
`${widgetBase}/prompt-bar-style/prompt-bar-style.css`,
],
+ 'image-to-video': [
+ `${widgetBase}/image-to-video/image-to-video.js`,
+ `${widgetBase}/image-to-video/image-to-video.css`,
+ ],
};
}
@@ -67,6 +71,7 @@ class WfInitiator {
],
'workflow-ai': [...bundledWidgetAssets],
'workflow-firefly': fireflyShared,
+ 'workflow-image-to-video': fireflyShared,
};
const commonResources = [
`${baseWfPath}/target-config.json`,
@@ -272,6 +277,11 @@ class WfInitiator {
sfList: new Set(['text-to-mage']),
stList: new Set(['prompt', 'tip', 'legal', 'generate']),
},
+ 'workflow-image-to-video': {
+ productName: 'Firefly',
+ sfList: new Set(['image-to-video']),
+ stList: null,
+ },
};
if (!wfName || !workflowCfg[wfName]) return [];
return {