From a2a4d44a779eae68f30f3898e2831a5d3eb3db80 Mon Sep 17 00:00:00 2001 From: Sanjay Saravanan Date: Thu, 16 Apr 2026 14:09:49 -0700 Subject: [PATCH] MWPW-192835: add image-to-video widget with dropzone, model/aspect-ratio selectors, and generate flow --- .../workflow/mocks/image-to-video-body.html | 26 ++ .../workflow/workflow.image-to-video.test.js | 341 +++++++++++++++ .../widgets/image-to-video/image-to-video.css | 414 ++++++++++++++++++ .../widgets/image-to-video/image-to-video.js | 384 ++++++++++++++++ .../workflow-image-to-video/action-binder.js | 303 +++++++++++++ .../target-config.json | 16 + unitylibs/core/workflow/workflow.js | 10 + 7 files changed, 1494 insertions(+) create mode 100644 test/core/workflow/mocks/image-to-video-body.html create mode 100644 test/core/workflow/workflow.image-to-video.test.js create mode 100644 unitylibs/core/widgets/image-to-video/image-to-video.css create mode 100644 unitylibs/core/widgets/image-to-video/image-to-video.js create mode 100644 unitylibs/core/workflow/workflow-image-to-video/action-binder.js create mode 100644 unitylibs/core/workflow/workflow-image-to-video/target-config.json 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 {