├── .gitignore ├── .storybook ├── main.js └── preview.js ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── Auth │ ├── LogInButton.tsx │ ├── LogInPage.tsx │ └── LogOutButton.tsx ├── Editor │ ├── AudioTimeline │ │ ├── AudioTimeline.tsx │ │ ├── AudioTimelineCursor.tsx │ │ ├── PlayBackControls.tsx │ │ ├── TextBox.tsx │ │ ├── TimelineRuler.tsx │ │ ├── Tools │ │ │ ├── AddLyricTextButton.tsx │ │ │ ├── AddVisualizerButton.tsx │ │ │ ├── CustomizationPanelButton.tsx │ │ │ ├── CustomizationSettingRow.tsx │ │ │ ├── FullScreenButton.tsx │ │ │ ├── LyricTextCustomizationToolPanel.tsx │ │ │ ├── ToolsView.tsx │ │ │ └── types.ts │ │ ├── store.ts │ │ └── utils.ts │ ├── EditDropDownMenu.tsx │ ├── Image │ │ ├── AI │ │ │ ├── AIImageGenerator.tsx │ │ │ ├── AIImageGeneratorError.tsx │ │ │ ├── DeleteImageButton.tsx │ │ │ ├── GenerateAIImageButton.tsx │ │ │ ├── GenerateImagesLog.tsx │ │ │ ├── GeneratedImageLogButton.tsx │ │ │ ├── PromptLogButton.tsx │ │ │ ├── store.ts │ │ │ ├── types.ts │ │ │ └── useAIImageService.ts │ │ └── Imported │ │ │ ├── ImagesManagerView.tsx │ │ │ └── ImportImageButton.tsx │ ├── LyricEditor.tsx │ ├── Lyrics │ │ ├── LyricPreview │ │ │ ├── EditableTextInput.tsx │ │ │ ├── LinearTimeSyncedLyricPreview.tsx │ │ │ ├── LyricPreview.tsx │ │ │ ├── LyricsTextView.tsx │ │ │ ├── PreviewWindowAlignGuide.tsx │ │ │ └── ResizableText.tsx │ │ ├── LyricReferenceView.tsx │ │ └── LyricsView.css │ ├── MediaContentSidePanel.tsx │ ├── SettingsSidePanel.tsx │ ├── Visualizer │ │ ├── AudioVisualizer.tsx │ │ ├── AudioVisualizerSettings.tsx │ │ └── store.ts │ ├── store.ts │ ├── types.ts │ └── utils.ts ├── Homepage.tsx ├── KonvaImage.tsx ├── Project │ ├── CreateNewProjectButton.tsx │ ├── CreateNewProjectForm.tsx │ ├── DeleteProjectButton.tsx │ ├── EditProjectButton.tsx │ ├── EditingModePicker.tsx │ ├── Featured │ │ └── FeaturedProject.tsx │ ├── LoadProjectListButton.tsx │ ├── Notice │ │ └── FixedResolutionUpgrade.tsx │ ├── Project.css │ ├── ProjectCard.tsx │ ├── ProjectList.tsx │ ├── ResolutionPicker.tsx │ ├── SaveButton.tsx │ ├── store.ts │ ├── types.ts │ └── useProjectService.ts ├── api │ ├── firebase.ts │ └── firebaseConfig.json ├── declarations.d.ts ├── github-mark.png ├── index.css ├── index.tsx ├── logo.svg ├── react-app-env.d.ts ├── reportWebVitals.ts ├── setupTests.ts └── utils.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | *.env 21 | *.mp3 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | 4 | addons: [ 5 | "@storybook/addon-links", 6 | "@storybook/addon-essentials", 7 | "@storybook/addon-interactions", 8 | "@storybook/preset-create-react-app", 9 | "@storybook/addon-mdx-gfm", 10 | ], 11 | 12 | framework: { 13 | name: "@storybook/react-webpack5", 14 | options: {}, 15 | }, 16 | 17 | docs: { 18 | autodocs: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | // import '!style-loader!css-loader!postcss-loader!tailwindcss/tailwind.css' 2 | // import '!style-loader!css-loader!postcss-loader!../src/index.css' 3 | 4 | export const parameters = { 5 | actions: { argTypesRegex: "^on[A-Z].*" }, 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/, 10 | }, 11 | }, 12 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Lyrictor", 4 | "zustand" 5 | ] 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lyrictor 2 | 3 | Final Cut Pro-inspired web editor for creating lyric animations for your favorite songs! Easily sync up the words to your tracks and watch them come to life on screen. 4 | 5 | Screenshot 2024-07-28 at 1 30 07 PM 6 | Screenshot 2024-07-21 at 12 47 35 AM 7 | Screenshot 2024-07-21 at 12 53 18 AM 8 | **NEW Feature** Linear time sync mode (Apple Music style) 9 | Screenshot 2024-10-21 at 10 35 59 PM 10 | 11 | ## Demo 12 | Stephen Sanchez - Until I Found You 13 | 14 | [![Alt text](https://img.youtube.com/vi/To29kD8vPoI/0.jpg)](https://www.youtube.com/watch?v=To29kD8vPoI) 15 | 16 | 17 | Lyrictor + AI (Preview) 18 | 19 | [![Alt text](https://img.youtube.com/vi/6oVsjVHntP8/0.jpg)](https://www.youtube.com/watch?v=6oVsjVHntP8) 20 | 21 | ## Built with: 22 | - React + Typescript 23 | - React Spectrum 24 | - zustand 25 | - Konva.js 26 | - waveform-data 27 | - react-use-audio-player (howler.js) 28 | 29 | ## Coming soon: 30 | - Stable Diffusion integration 31 | - Lyric text animations and styling 32 | - Share and view other people's lyric animations 33 | - Youtube url support 34 | - Improved timeline usability 35 | - Export as video 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lyrictor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@adobe/react-spectrum": "^3.32.2", 7 | "@fontsource-variable/big-shoulders-inline-display": "^5.0.19", 8 | "@fontsource-variable/caveat": "^5.0.17", 9 | "@fontsource-variable/comfortaa": "^5.0.19", 10 | "@fontsource-variable/dancing-script": "^5.0.17", 11 | "@fontsource-variable/darker-grotesque": "^5.0.4", 12 | "@fontsource-variable/edu-nsw-act-foundation": "^5.0.8", 13 | "@fontsource-variable/inter": "^5.0.17", 14 | "@fontsource-variable/merienda": "^5.0.12", 15 | "@fontsource-variable/montserrat": "^5.0.18", 16 | "@fontsource-variable/open-sans": "^5.0.28", 17 | "@fontsource-variable/red-hat-display": "^5.0.20", 18 | "@fontsource-variable/roboto-mono": "^5.0.18", 19 | "@fontsource/roboto": "^5.0.12", 20 | "@react-hook/window-size": "^3.0.7", 21 | "@react-spectrum/toast": "^3.0.0-beta.10", 22 | "@spectrum-css/textfield": "^6.0.29", 23 | "@testing-library/jest-dom": "^5.14.1", 24 | "@testing-library/react": "^12.0.0", 25 | "@testing-library/user-event": "^13.2.1", 26 | "@types/howler": "^2.2.4", 27 | "@types/jest": "^27.0.1", 28 | "@types/node": "^16.7.13", 29 | "@types/react": "^18.3.3", 30 | "@types/react-color": "^3.0.12", 31 | "@types/react-dom": "^18.2.17", 32 | "@types/react-outside-click-handler": "^1.3.3", 33 | "@types/react-scrollbar": "^0.5.6", 34 | "@vercel/analytics": "^1.2.2", 35 | "draft-js": "^0.11.7", 36 | "filepond": "^4.30.3", 37 | "firebase": "^9.6.10", 38 | "flowbite-react": "^0.7.3", 39 | "format-duration": "^1.4.0", 40 | "framer-motion": "^11.11.9", 41 | "howler": "^2.2.3", 42 | "konva": "^8.3.2", 43 | "lodash.debounce": "^4.0.8", 44 | "lodash.throttle": "^4.1.1", 45 | "re-resizable": "^6.9.17", 46 | "react": "^18.2.0", 47 | "react-color": "^2.19.3", 48 | "react-dom": "^18.2.0", 49 | "react-dropzone": "^12.0.4", 50 | "react-filepond": "^7.1.1", 51 | "react-hooks-use-previous": "^1.0.0-rc2", 52 | "react-konva": "^18.2.10", 53 | "react-konva-utils": "^1.0.5", 54 | "react-outside-click-handler": "^1.3.0", 55 | "react-router-dom": "^6.22.2", 56 | "react-scripts": "^5.0.1", 57 | "react-scrollbar": "^0.5.6", 58 | "react-scrollbars-custom": "^4.1.1", 59 | "react-social-login-buttons": "^3.6.0", 60 | "react-split-pane": "^2.0.3", 61 | "react-type-animation": "^3.2.0", 62 | "react-use-audio-player": "^1.2.5", 63 | "react-use-previous": "^1.0.0", 64 | "tailwindcss": "^3.4.1", 65 | "typescript": "^5.5.4", 66 | "use-image": "^1.1.1", 67 | "uuid": "^9.0.1", 68 | "waveform-data": "^4.4.0", 69 | "wavesurfer-react": "^3.0.1", 70 | "web-vitals": "^2.1.0", 71 | "zustand": "^4.4.7" 72 | }, 73 | "scripts": { 74 | "start": "react-scripts start", 75 | "build": "react-scripts build", 76 | "test": "react-scripts test", 77 | "eject": "react-scripts eject", 78 | "storybook": "storybook dev -p 6006 -s public", 79 | "build-storybook": "storybook build -s public" 80 | }, 81 | "eslintConfig": { 82 | "extends": [ 83 | "react-app", 84 | "react-app/jest" 85 | ], 86 | "overrides": [ 87 | { 88 | "files": [ 89 | "**/*.stories.*" 90 | ], 91 | "rules": { 92 | "import/no-anonymous-default-export": "off" 93 | } 94 | } 95 | ] 96 | }, 97 | "browserslist": { 98 | "production": [ 99 | ">0.2%", 100 | "not dead", 101 | "not op_mini all" 102 | ], 103 | "development": [ 104 | "last 1 chrome version", 105 | "last 1 firefox version", 106 | "last 1 safari version" 107 | ] 108 | }, 109 | "devDependencies": { 110 | "@storybook/addon-actions": "^7.6.17", 111 | "@storybook/addon-essentials": "^7.6.17", 112 | "@storybook/addon-interactions": "^7.6.17", 113 | "@storybook/addon-links": "^7.6.17", 114 | "@storybook/addon-mdx-gfm": "^7.6.17", 115 | "@storybook/addon-styling-webpack": "^0.0.6", 116 | "@storybook/node-logger": "^7.6.17", 117 | "@storybook/preset-create-react-app": "^7.6.17", 118 | "@storybook/react": "^7.6.17", 119 | "@storybook/react-webpack5": "^7.6.17", 120 | "@storybook/testing-library": "^0.0.9", 121 | "@types/draft-js": "^0.11.9", 122 | "@types/lodash.debounce": "^4.0.7", 123 | "@types/lodash.throttle": "^4.1.7", 124 | "css-loader": "^6.10.0", 125 | "storybook": "^7.6.17", 126 | "style-loader": "^3.3.4", 127 | "webpack": "^5.94.0" 128 | }, 129 | "resolutions": { 130 | "nth-check": "2.0.1", 131 | "trim": "0.0.3", 132 | "trim-newlines": "3.0.1", 133 | "glob-parent": "5.1.2", 134 | "node-fetch": "2.6.7", 135 | "ws": "7.5.10" 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jtCodes/lyrictor/21f404b38632d857946550d8ae311e1b5a5a24c2/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Lyrictor 9 | 13 | 17 | 18 | 22 | 23 | 32 | lyrictor 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jtCodes/lyrictor/21f404b38632d857946550d8ae311e1b5a5a24c2/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jtCodes/lyrictor/21f404b38632d857946550d8ae311e1b5a5a24c2/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from "@vercel/analytics/react"; 2 | import "./App.css"; 3 | import LyricEditor from "./Editor/LyricEditor"; 4 | import { defaultTheme, Provider } from "@adobe/react-spectrum"; 5 | import { ToastContainer } from "@react-spectrum/toast"; 6 | import { AudioPlayerProvider } from "react-use-audio-player"; 7 | import { useEffect, useState } from "react"; 8 | import { auth } from "./api/firebase"; 9 | import { User } from "firebase/auth"; 10 | import LogInButton from "./Auth/LogInButton"; 11 | import LogInPage from "./Auth/LogInPage"; 12 | import CreateNewProject from "./Project/CreateNewProjectForm"; 13 | import Homepage from "./Homepage"; 14 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 15 | 16 | const router = createBrowserRouter([ 17 | { 18 | path: "/", 19 | element: , 20 | }, 21 | { 22 | path: "/edit", 23 | element: , 24 | }, 25 | ]); 26 | 27 | function App() { 28 | const [user, setUser] = useState(); 29 | 30 | useEffect(() => { 31 | auth.onAuthStateChanged((user) => { 32 | if (user) { 33 | setUser(user); 34 | } 35 | }); 36 | }, []); 37 | 38 | return ( 39 | 40 |
41 | 42 | {/* {user ? ( 43 | 44 | 45 | 46 | ) : ( 47 | 48 | )} */}{" "} 49 | 50 | 51 | 52 | 53 |
54 |
55 | ); 56 | } 57 | 58 | export default App; 59 | -------------------------------------------------------------------------------- /src/Auth/LogInButton.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleAuthProvider, signInWithPopup } from "firebase/auth"; 2 | import { GoogleLoginButton } from "react-social-login-buttons"; 3 | import { auth, googleProvider } from "../api/firebase"; 4 | 5 | // TODO: GoogleLoginButton not working after upgrade 6 | export default function LogInButton() { 7 | return ( 8 | <>LoginButtonTODO 9 | // { 11 | // signInWithPopup(auth, googleProvider) 12 | // .then((result) => { 13 | // // This gives you a Google Access Token. You can use it to access the Google API. 14 | // const credential = GoogleAuthProvider.credentialFromResult(result); 15 | 16 | // if (credential) { 17 | // console.log(credential); 18 | // } 19 | // // ... 20 | // }) 21 | // .catch((error) => { 22 | // // Handle Errors here. 23 | // const errorCode = error.code; 24 | // const errorMessage = error.message; 25 | // // The email of the user's account used. 26 | // const email = error.email; 27 | // // The AuthCredential type that was used. 28 | // const credential = GoogleAuthProvider.credentialFromError(error); 29 | // // ... 30 | // }); 31 | // }} 32 | // /> 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/Auth/LogInPage.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, View } from "@adobe/react-spectrum"; 2 | import React from "react"; 3 | import LogInButton from "./LogInButton"; 4 | 5 | export default function LogInPage() { 6 | return ( 7 | 8 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/Auth/LogOutButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton } from "@adobe/react-spectrum"; 2 | import { Text } from "@adobe/react-spectrum"; 3 | import { auth, googleProvider } from "../api/firebase"; 4 | import React from "react"; 5 | 6 | export default function LogOutButton() { 7 | return ( 8 | { 10 | auth.signOut(); 11 | }} 12 | > 13 | Logout 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/Editor/AudioTimeline/AudioTimelineCursor.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Layer, Rect, Stage } from "react-konva"; 3 | import { useAudioPosition } from "react-use-audio-player"; 4 | 5 | interface AudioTimelineCursorProps { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | export default function AudioTimelineCursor(props: AudioTimelineCursorProps) { 11 | const { width, height } = props; 12 | const [cursorX, setCursorX] = useState(0); 13 | const { percentComplete, duration, seek, position } = useAudioPosition({ 14 | highRefreshRate: true, 15 | }); 16 | 17 | useEffect(() => { 18 | setCursorX((percentComplete / 100) * width); 19 | }, [position, width]); 20 | 21 | return ( 22 | { 26 | seek((e.evt.layerX / width) * duration); 27 | console.log(e.evt.layerX); 28 | }} 29 | > 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/Editor/AudioTimeline/PlayBackControls.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, Button, Flex } from "@adobe/react-spectrum"; 2 | import Play from "@spectrum-icons/workflow/Play"; 3 | import Pause from "@spectrum-icons/workflow/Pause"; 4 | 5 | interface PlayBackControlsProps { 6 | isPlaying: boolean; 7 | onPlayPauseClicked: () => void; 8 | } 9 | 10 | export default function PlayPauseButton(props: PlayBackControlsProps) { 11 | return ( 12 | 13 | 18 | {props.isPlaying ? : } 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/Editor/AudioTimeline/TimelineRuler.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { Stage, Group, Line, Layer, Rect, Text } from "react-konva"; 3 | import { secondsToPixels } from "../utils"; 4 | 5 | const HEIGHT: number = 15; 6 | const BACKGROUND_COLOR: string = "rgba(40,40,40, 0.8)"; 7 | const SIG_TICK_COLOR: string = "rgba(128, 128, 128, 1)"; 8 | const NORMAL_TICK_COLOR: string = "rgba(128, 128, 128, 0.45)"; 9 | const NORMAL_LABEL_COLOR: string = "rgba(128, 128, 128, 0.8)"; 10 | 11 | interface TickMark { 12 | isSignificant: boolean; 13 | markX: number; 14 | label: string; 15 | } 16 | 17 | export default function TimelineRuler({ 18 | width, 19 | windowWidth, 20 | scrollXOffset, 21 | duration, 22 | }: { 23 | width: number; 24 | windowWidth: number; 25 | scrollXOffset: number; 26 | duration: number; 27 | }) { 28 | const [tickMarkData, setTickMarkData] = useState([]); 29 | const tickMarks = useMemo( 30 | () => 31 | tickMarkData.map((mark, i) => ( 32 | 33 | 41 | {i % 5 === 0 ? ( 42 | 51 | ) : null} 52 | 53 | )), 54 | [tickMarkData, duration] 55 | ); 56 | 57 | useEffect(() => { 58 | const tickMarks: TickMark[] = []; 59 | 60 | for (let i = 0; i <= duration; i += 1) { 61 | const second = Math.round(i); 62 | const markX = secondsToPixels(second, duration, width); 63 | tickMarks.push({ 64 | isSignificant: second % 5 === 0, 65 | markX, 66 | label: String(second), 67 | }); 68 | } 69 | 70 | setTickMarkData(tickMarks); 71 | }, [width, duration]); 72 | 73 | return ( 74 | <> 75 | 76 | 83 | 84 | {tickMarks} 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/Editor/AudioTimeline/Tools/AddLyricTextButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, Tooltip, TooltipTrigger } from "@adobe/react-spectrum"; 2 | import TextAdd from "@spectrum-icons/workflow/TextAdd"; 3 | import { useProjectStore } from "../../../Project/store"; 4 | 5 | export default function AddLyricTextButton({ 6 | position, 7 | text = "text", 8 | }: { 9 | position: number; 10 | text?: string; 11 | }) { 12 | const addNewLyricText = useProjectStore((state) => state.addNewLyricText); 13 | 14 | return ( 15 | 16 | { 20 | addNewLyricText(text, position, false, "", false, undefined); 21 | }} 22 | > 23 | 24 | 25 | Add new lyric at cursor 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/Editor/AudioTimeline/Tools/AddVisualizerButton.tsx: -------------------------------------------------------------------------------- 1 | import { TooltipTrigger, ActionButton, Tooltip } from "@adobe/react-spectrum"; 2 | import GraphStreamRankedAdd from "@spectrum-icons/workflow/GraphStreamRankedAdd"; 3 | import { useProjectStore } from "../../../Project/store"; 4 | import { DEFAULT_VISUALIZER_SETTING } from "../../Visualizer/store"; 5 | 6 | export default function AddVisualizerButton({ 7 | position, 8 | }: { 9 | position: number; 10 | }) { 11 | const addNewLyricText = useProjectStore((state) => state.addNewLyricText); 12 | 13 | function handleClick() { 14 | addNewLyricText( 15 | "", 16 | position, 17 | false, 18 | "", 19 | true, 20 | JSON.parse(JSON.stringify(DEFAULT_VISUALIZER_SETTING)) 21 | ); 22 | } 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | Add new visualizer at cursor 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/Editor/AudioTimeline/Tools/CustomizationPanelButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton } from "@adobe/react-spectrum"; 2 | import { useEditorStore } from "../../store"; 3 | import GraphBullet from "@spectrum-icons/workflow/GraphBullet"; 4 | 5 | export default function CustomizationPanelButton() { 6 | const isCustomizationPanelOpen = useEditorStore( 7 | (state) => state.isCustomizationPanelOpen 8 | ); 9 | const toggleCustomizationPanelState = useEditorStore( 10 | (state) => state.toggleCustomizationPanelOpenState 11 | ); 12 | 13 | function onPress() { 14 | toggleCustomizationPanelState(); 15 | } 16 | 17 | return ( 18 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/Editor/AudioTimeline/Tools/CustomizationSettingRow.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | View, 3 | Text, 4 | Flex, 5 | Slider, 6 | Picker, 7 | Item, 8 | TextArea, 9 | } from "@adobe/react-spectrum"; 10 | import { ColorResult, RGBColor, SketchPicker } from "react-color"; 11 | import { useRef, useState, useMemo } from "react"; 12 | import { useProjectStore } from "../../../Project/store"; 13 | import { 14 | DEFAULT_TEXT_PREVIEW_FONT_NAME, 15 | DEFAULT_TEXT_PREVIEW_FONT_SIZE, 16 | LyricText, 17 | } from "../../types"; 18 | import { CUSTOMIZATION_PANEL_WIDTH } from "./LyricTextCustomizationToolPanel"; 19 | import { TextCustomizationSettingType } from "./types"; 20 | import OutsideClickHandler from "react-outside-click-handler"; 21 | 22 | export function TextReferenceTextAreaRow({ 23 | lyricText, 24 | }: { 25 | lyricText: LyricText; 26 | }) { 27 | const modifyLyricTexts = useProjectStore((state) => state.modifyLyricTexts); 28 | const [value, setValue] = useState(lyricText.text); 29 | 30 | return ( 31 | 32 | 115 | 116 | 117 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 149 | 150 | {currentGenFileUrl && currentGenParams ? ( 151 | 152 | 161 | 162 | Seed: {currentGenParams.seed} 163 | 164 | 165 | ) : null} 166 | 167 | 168 | 169 | 170 | 177 | 178 | 179 | {selectedImageLogItem ? ( 180 | <> 181 | 182 | 183 | Selected Image 184 | 185 | seed: {selectedImageLogItem.prompt.seed} 186 | 187 | 188 | 197 | 198 | 199 | ) : null} 200 | 201 | 202 | 203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/AIImageGeneratorError.tsx: -------------------------------------------------------------------------------- 1 | import { View, Well, Link, Text } from "@adobe/react-spectrum"; 2 | 3 | export default function AIImageGeneratorError() { 4 | return ( 5 | 6 | 7 | 8 | 9 | !! stable-diffusion-webui not detected !! 10 | 11 | 12 |
13 | 14 | 18 | https://github.com/AUTOMATIC1111/stable-diffusion-webui 19 | 20 | 21 |
22 |
23 | Make sure: 24 | 25 |
    26 |
  • 27 | stable-diffusion-webui is running with command{" "} 28 | 29 | 30 | {" "} 31 | --cors-allow-origins "*" 32 | 33 | 34 | for example: 35 | ./webui.sh --cors-allow-origins "*" 36 |

    37 |
  • 38 |
  • this not running on Safari
  • 39 |
40 |
41 |
42 | 43 | Close and re-open this component to refresh the status. 44 | 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/DeleteImageButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Text, Tooltip, TooltipTrigger } from "@adobe/react-spectrum"; 2 | import { useProjectService } from "../../../Project/useProjectService"; 3 | import { useAIImageGeneratorStore } from "./store"; 4 | import Delete from "@spectrum-icons/workflow/Delete"; 5 | 6 | export default function DeleteImageButton() { 7 | const [saveProject] = useProjectService(); 8 | const hideImage = useAIImageGeneratorStore((state) => state.hideImage); 9 | const selectedImage = useAIImageGeneratorStore( 10 | (state) => state.selectedImageLogItem 11 | ); 12 | 13 | function onPress() { 14 | if (selectedImage) { 15 | hideImage(selectedImage.url); 16 | saveProject(); 17 | } 18 | } 19 | 20 | return ( 21 | 22 | 25 | 26 | Remove image from log. (Does not delete image from folder) 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/GenerateAIImageButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionButton, 3 | Button, 4 | ButtonGroup, 5 | Content, 6 | Dialog, 7 | DialogTrigger, 8 | Divider, 9 | Header, 10 | Heading, 11 | Text, 12 | Tooltip, 13 | TooltipTrigger, 14 | } from "@adobe/react-spectrum"; 15 | import { useProjectStore } from "../../../Project/store"; 16 | import AIImageGenerator from "./AIImageGenerator"; 17 | import PromptLogButton from "./PromptLogButton"; 18 | import { useAIImageGeneratorStore } from "./store"; 19 | import ImageAutoMode from "@spectrum-icons/workflow/ImageAutoMode"; 20 | 21 | export default function GenerateAIImageButton({ 22 | position, 23 | }: { 24 | position: number; 25 | }) { 26 | const setIsPopupOpen = useProjectStore((state) => state.setIsPopupOpen); 27 | const addNewLyricText = useProjectStore((state) => state.addNewLyricText); 28 | const selectedImageLogItem = useAIImageGeneratorStore( 29 | (state) => state.selectedImageLogItem 30 | ); 31 | 32 | function handleConfirmClick(close: () => void) { 33 | if (selectedImageLogItem) { 34 | addNewLyricText( 35 | "", 36 | position, 37 | true, 38 | selectedImageLogItem.url, 39 | false, 40 | undefined 41 | ); 42 | } 43 | onDiaglogClosed(close); 44 | } 45 | 46 | function handleCancelClick(close: () => void) { 47 | onDiaglogClosed(close); 48 | } 49 | 50 | function onDiaglogClosed(close: () => void) { 51 | close(); 52 | } 53 | 54 | return ( 55 | { 58 | setIsPopupOpen(isOpen); 59 | }} 60 | > 61 | 62 | 63 | 64 | {(close) => ( 65 | 66 | Add Image 67 | 68 | 69 | 70 | 71 | 72 | 78 | 85 | 86 | 87 | )} 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/GenerateImagesLog.tsx: -------------------------------------------------------------------------------- 1 | import { View, Flex, Image, Text } from "@adobe/react-spectrum"; 2 | import DeleteImageButton from "./DeleteImageButton"; 3 | import { useAIImageGeneratorStore } from "./store"; 4 | 5 | export default function GenerateImagesLog({ height }: { height: string }) { 6 | const generatedImageLog = useAIImageGeneratorStore( 7 | (state) => state.generatedImageLog 8 | ); 9 | const selectedImageLogItem = useAIImageGeneratorStore( 10 | (state) => state.selectedImageLogItem 11 | ); 12 | const setSelectedImageLogItem = useAIImageGeneratorStore( 13 | (state) => state.setSelectedImageLogTiem 14 | ); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | Image Log 22 | 23 | {selectedImageLogItem ? : null} 24 | 25 | 26 | 27 | 28 | {generatedImageLog.map((image) => ( 29 |
{ 32 | setSelectedImageLogItem(image); 33 | }} 34 | > 35 | 45 | Sky and roof 51 | 52 |
53 | ))} 54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/GeneratedImageLogButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DialogTrigger, 3 | ActionButton, 4 | Dialog, 5 | Heading, 6 | Divider, 7 | Content, 8 | Item, 9 | ListView, 10 | Text, 11 | Tooltip, 12 | TooltipTrigger, 13 | } from "@adobe/react-spectrum"; 14 | import Images from "@spectrum-icons/workflow/Images"; 15 | import { useAIImageGeneratorStore } from "./store"; 16 | 17 | export default function GenerateImageLogButton() { 18 | const promptLog = useAIImageGeneratorStore((state) => state.promptLog); 19 | const setPrompt = useAIImageGeneratorStore((state) => state.setPrompt); 20 | 21 | return ( 22 | 23 | 24 | 25 | {promptLog.length} 26 | 27 | 28 | {(close) => ( 29 | 30 | Prompt Log 31 | 32 | 33 | { 37 | setPrompt(promptLog[Number(key)]); 38 | close(); 39 | }} 40 | > 41 | {promptLog.map((prompt, index) => ( 42 | {prompt.prompt} 43 | ))} 44 | 45 | 46 | 47 | )} 48 | 49 | Prompt log 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/PromptLogButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DialogTrigger, 3 | ActionButton, 4 | Dialog, 5 | Heading, 6 | Divider, 7 | Content, 8 | Item, 9 | ListView, 10 | Text, 11 | Tooltip, 12 | TooltipTrigger, 13 | Flex, 14 | } from "@adobe/react-spectrum"; 15 | import AnnotatePen from "@spectrum-icons/workflow/AnnotatePen"; 16 | import { initialPrompt, useAIImageGeneratorStore } from "./store"; 17 | 18 | export default function PromptLogButton() { 19 | const promptLog = useAIImageGeneratorStore((state) => state.promptLog); 20 | const setPrompt = useAIImageGeneratorStore((state) => state.setPrompt); 21 | 22 | return ( 23 | 24 | 25 | 26 | {promptLog.length} 27 | 28 | 29 | {(close) => ( 30 | 31 | Prompt Log 32 | 33 | 34 | { 38 | const prompt = promptLog[Number(key)]; 39 | setPrompt({ ...initialPrompt, prompt: prompt.prompt }); 40 | close(); 41 | }} 42 | > 43 | {promptLog.map((prompt, index) => ( 44 | 45 | 46 | {prompt.prompt} 47 | 48 | 49 | ))} 50 | 51 | 52 | 53 | )} 54 | 55 | Prompt log 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GeneratedImage, 3 | PredictParams, 4 | PromptParams, 5 | PromptParamsType, 6 | } from "./types"; 7 | import create, { GetState, SetState } from "zustand"; 8 | 9 | export interface AIImageGeneratorStore { 10 | currentGenFileUrl?: string; 11 | setCurrentGenFileUrl: (url: string) => void; 12 | 13 | currentGenParams?: PredictParams; 14 | setCurrentGenParams: (params: PredictParams) => void; 15 | 16 | prompt: PromptParams; 17 | setPrompt: (prompt: PromptParams) => void; 18 | updatePrompt: (type: PromptParamsType, value: any) => void; 19 | 20 | promptLog: PromptParams[]; 21 | logPrompt: (prompt: PromptParams) => void; 22 | setPromptLog: (promptLog: PromptParams[]) => void; 23 | 24 | generatedImageLog: GeneratedImage[]; 25 | logGeneratedImage: (image: GeneratedImage) => void; 26 | setGeneratedImageLog: (generatedImageLog: GeneratedImage[]) => void; 27 | 28 | selectedImageLogItem: GeneratedImage | undefined; 29 | setSelectedImageLogTiem: (image: GeneratedImage) => void; 30 | 31 | hiddenImages: string[]; 32 | hideImage: (imageUrl: string) => void; 33 | 34 | reset: () => void; 35 | } 36 | 37 | export const getImageFileUrl = (url: string) => { 38 | return `http://127.0.0.1:7860/file=${url}`; 39 | }; 40 | 41 | export const initialPrompt = { 42 | prompt: "", 43 | negative_prompt: "", 44 | seed: -1, 45 | width: 0, 46 | height: 0, 47 | sampler_name: "", 48 | cfg_scale: 0, 49 | steps: 0, 50 | }; 51 | 52 | export const useAIImageGeneratorStore = create( 53 | ( 54 | set: SetState, 55 | get: GetState 56 | ): AIImageGeneratorStore => ({ 57 | reset: () => { 58 | set({ 59 | prompt: initialPrompt, 60 | promptLog: [], 61 | generatedImageLog: [], 62 | selectedImageLogItem: undefined, 63 | currentGenFileUrl: undefined, 64 | }); 65 | }, 66 | currentGenFileUrl: undefined, 67 | setCurrentGenFileUrl: (url: string) => { 68 | set({ 69 | currentGenFileUrl: getImageFileUrl(url), 70 | }); 71 | }, 72 | currentGenParams: undefined, 73 | setCurrentGenParams: (params: PredictParams) => { 74 | set({ 75 | currentGenParams: params, 76 | }); 77 | }, 78 | prompt: initialPrompt, 79 | setPrompt: (prompt: PromptParams) => { 80 | set({ 81 | prompt, 82 | }); 83 | }, 84 | updatePrompt: (type: PromptParamsType, value: any) => { 85 | const { prompt } = get(); 86 | if (prompt) { 87 | set({ 88 | prompt: { ...prompt, [type]: value }, 89 | }); 90 | } 91 | }, 92 | promptLog: [], 93 | logPrompt: (prompt: PromptParams) => { 94 | const { promptLog } = get(); 95 | set({ 96 | promptLog: [ 97 | prompt, 98 | ...promptLog.filter( 99 | (oldPrompt) => oldPrompt.prompt !== prompt.prompt 100 | ), 101 | ], 102 | }); 103 | }, 104 | setPromptLog: (promptLog: PromptParams[]) => { 105 | set({ 106 | promptLog, 107 | }); 108 | }, 109 | generatedImageLog: [], 110 | logGeneratedImage: (image: GeneratedImage) => { 111 | const { generatedImageLog } = get(); 112 | set({ 113 | generatedImageLog: [image, ...generatedImageLog], 114 | }); 115 | }, 116 | setGeneratedImageLog: (generatedImageLog: GeneratedImage[]) => { 117 | set({ 118 | generatedImageLog, 119 | }); 120 | }, 121 | selectedImageLogItem: undefined, 122 | setSelectedImageLogTiem: (image: GeneratedImage) => { 123 | const { selectedImageLogItem } = get(); 124 | set({ 125 | selectedImageLogItem: 126 | image.url === selectedImageLogItem?.url ? undefined : image, 127 | }); 128 | }, 129 | hiddenImages: [], 130 | hideImage: (imageUrl: string) => { 131 | const { generatedImageLog } = get(); 132 | set({ 133 | generatedImageLog: generatedImageLog.filter( 134 | (image) => image.url !== imageUrl 135 | ), 136 | selectedImageLogItem: undefined, 137 | }); 138 | }, 139 | }) 140 | ); 141 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/types.ts: -------------------------------------------------------------------------------- 1 | export interface PredictResp { 2 | data: [PredictRespFileInfo[], string | PredictParams, string, string]; 3 | is_generating: boolean; 4 | duration: number; 5 | average_duration: number; 6 | } 7 | 8 | interface PredictRespFileInfo { 9 | name: string; 10 | data: any; 11 | is_file: boolean; 12 | } 13 | 14 | export interface PredictParams { 15 | prompt: string; 16 | all_prompts: string[]; 17 | negative_prompt: string; 18 | all_negative_prompts: string[]; 19 | seed: number; 20 | all_seeds: number[]; 21 | subseed: number; 22 | all_subseeds: number[]; 23 | subseed_strength: number; 24 | width: number; 25 | height: number; 26 | sampler_name: string; 27 | cfg_scale: number; 28 | steps: number; 29 | batch_size: number; 30 | restore_faces: boolean; 31 | face_restoration_model: any; 32 | sd_model_hash: string; 33 | seed_resize_from_w: number; 34 | seed_resize_from_h: number; 35 | denoising_strength: any; 36 | extra_generation_params: any; 37 | index_of_first_image: number; 38 | infotexts: string[]; 39 | styles: string[]; 40 | job_timestamp: string; 41 | clip_skip: number; 42 | is_using_inpainting_conditioning: boolean; 43 | } 44 | 45 | export interface PromptParams { 46 | [PromptParamsType.prompt]: string; 47 | [PromptParamsType.negative_prompt]: string; 48 | [PromptParamsType.seed]: number; 49 | [PromptParamsType.width]: number; 50 | [PromptParamsType.height]: number; 51 | [PromptParamsType.sampler_name]: string; 52 | [PromptParamsType.cfg_scale]: number; 53 | [PromptParamsType.steps]: number; 54 | } 55 | 56 | export enum PromptParamsType { 57 | prompt = "prompt", 58 | negative_prompt = "negative_prompt", 59 | seed = "seed", 60 | width = "width", 61 | height = "height", 62 | sampler_name = "sampler_name", 63 | cfg_scale = "cfg_scale", 64 | steps = "steps", 65 | } 66 | 67 | /** 68 | * { 69 | "fn_index": 66, 70 | "data": [ 71 | "dark, buildings, woods, night, misty", 72 | "shit", 73 | "None", 74 | "None", 75 | 20, 76 | "DPM++ 2M Karras", 77 | false, 78 | false, 79 | 1, 80 | 1, 81 | 7, 82 | -1, 83 | -1, 84 | 0, 85 | 0, 86 | 0, 87 | false, 88 | 512, 89 | 512, 90 | false, 91 | 0.7, 92 | 2, 93 | "Latent", 94 | 0, 95 | 0, 96 | 0, 97 | "None", 98 | false, 99 | false, 100 | false, 101 | false, 102 | "", 103 | "Seed", 104 | "", 105 | "Nothing", 106 | "", 107 | true, 108 | false, 109 | false, 110 | [] 111 | ], 112 | "session_hash": "" 113 | } 114 | */ 115 | export interface PredictRequestBody { 116 | fn_index: number; 117 | data: any[]; 118 | session_hash: string; 119 | } 120 | 121 | export interface GeneratedImage { 122 | url: string; 123 | prompt: PromptParams; 124 | } 125 | -------------------------------------------------------------------------------- /src/Editor/Image/AI/useAIImageService.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | PredictParams, 4 | PredictRequestBody, 5 | PredictResp, 6 | PromptParams, 7 | } from "./types"; 8 | 9 | const LOCAL_WEB_UI_URL: string = "http://127.0.0.1:7860"; 10 | const PREDICT_PATH: string = "/run/predict/"; 11 | 12 | /** 13 | * 14 | * handles image generation process: request, status, image url 15 | */ 16 | export function useAIImageService(isLocal: boolean) { 17 | const url: string = isLocal ? LOCAL_WEB_UI_URL : ""; 18 | const [isLoading, setIsLoading] = useState(false); 19 | const [isLocalAIRunning, setIsLocalAIRunning] = useState(false); 20 | 21 | async function checkIfLocalAIRunning(): Promise { 22 | try { 23 | const resp = await fetch(url + PREDICT_PATH, { 24 | method: "POST", 25 | headers: { 26 | Accept: "application/json", 27 | "Content-Type": "application/json", 28 | }, 29 | body: JSON.stringify({ 30 | fn_index: 94, 31 | data: [null, "", ""], 32 | session_hash: "y3c2bcanj7q", 33 | }), 34 | }); 35 | setIsLocalAIRunning(resp.ok); 36 | } catch (error) { 37 | setIsLocalAIRunning(false); 38 | } 39 | return false; 40 | } 41 | 42 | async function generateImage(prompt: PromptParams): Promise { 43 | setIsLoading(true); 44 | const url: string = isLocal ? LOCAL_WEB_UI_URL : ""; 45 | const generateImageUrl = url + PREDICT_PATH; 46 | const rawResponse = await fetch(generateImageUrl, { 47 | method: "POST", 48 | headers: { 49 | Accept: "application/json", 50 | "Content-Type": "application/json", 51 | }, 52 | body: JSON.stringify(createGenerateImageRequestBody(prompt)), 53 | }); 54 | const content: PredictResp = await rawResponse.json(); 55 | setIsLoading(false); 56 | 57 | const predictParams: PredictParams = JSON.parse( 58 | content.data[1] as string 59 | ) as PredictParams; 60 | content.data[1] = predictParams; 61 | 62 | return content; 63 | } 64 | 65 | function createGenerateImageRequestBody( 66 | prompt: PromptParams 67 | ): PredictRequestBody { 68 | console.log(prompt); 69 | return { 70 | fn_index: 77, 71 | data: [ 72 | "task(ucrk5a8tebr85os)", 73 | prompt.prompt, 74 | "nude, nsfw", 75 | [""], 76 | 20, 77 | "DPM++ 2M Karras", 78 | false, 79 | false, 80 | 1, 81 | 1, 82 | 7, 83 | prompt.seed, 84 | -1, 85 | 0, 86 | 0, 87 | 0, 88 | false, 89 | 512, 90 | 768, 91 | false, 92 | 0.7, 93 | 2, 94 | "Latent", 95 | 0, 96 | 0, 97 | 0, 98 | [], 99 | "None", 100 | false, 101 | false, 102 | "positive", 103 | "comma", 104 | false, 105 | false, 106 | "", 107 | "Seed", 108 | "", 109 | "Nothing", 110 | "", 111 | "Nothing", 112 | "", 113 | true, 114 | false, 115 | false, 116 | false, 117 | [], 118 | "", 119 | "", 120 | "", 121 | ], 122 | session_hash: "", 123 | }; 124 | } 125 | 126 | return [ 127 | generateImage, 128 | isLoading, 129 | checkIfLocalAIRunning, 130 | isLocalAIRunning, 131 | ] as const; 132 | } 133 | -------------------------------------------------------------------------------- /src/Editor/Image/Imported/ImagesManagerView.tsx: -------------------------------------------------------------------------------- 1 | import { View, Flex, Image, Button } from "@adobe/react-spectrum"; 2 | import ImportImageButton, { ImageItem } from "./ImportImageButton"; 3 | import { useProjectStore } from "../../../Project/store"; 4 | import { useState } from "react"; 5 | import { useAudioPosition } from "react-use-audio-player"; 6 | 7 | export default function ImagesManagerView({ 8 | containerHeight, 9 | }: { 10 | containerHeight: number; 11 | }) { 12 | const { position } = useAudioPosition({ 13 | highRefreshRate: false, 14 | }); 15 | const addNewLyricText = useProjectStore((state) => state.addNewLyricText); 16 | const images = useProjectStore((state) => state.images); 17 | const deleteImage = useProjectStore((state) => state.removeImagesById); 18 | const [selectedImage, setSelectedImage] = useState(); 19 | 20 | function handleAddSelectedImageToTimeline() { 21 | if (selectedImage) { 22 | addNewLyricText("", position, true, selectedImage.url, false, undefined); 23 | } 24 | } 25 | 26 | function handleDeleteSelectedImage() { 27 | if (selectedImage) { 28 | deleteImage(selectedImage.id); 29 | } 30 | setSelectedImage(undefined); 31 | } 32 | 33 | return ( 34 | 35 | 36 | 43 | 50 | {images.map((image, index) => ( 51 |
{ 54 | setSelectedImage(image); 55 | }} 56 | > 57 | 66 | 67 | 68 |
69 | ))} 70 |
71 |
72 | {selectedImage ? ( 73 | 82 | 87 | 96 | 106 | 107 | 108 | ) : null} 109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/Editor/Image/Imported/ImportImageButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | View, 3 | ActionButton, 4 | DialogContainer, 5 | useDialogContainer, 6 | Dialog, 7 | Heading, 8 | Divider, 9 | Content, 10 | Form, 11 | TextField, 12 | ButtonGroup, 13 | Button, 14 | Flex, 15 | Text, 16 | LabeledValue, 17 | } from "@adobe/react-spectrum"; 18 | import { useState, useMemo } from "react"; 19 | import { useProjectStore } from "../../../Project/store"; 20 | 21 | export default function ImportImageButton() { 22 | const [isOpen, setOpen] = useState(false); 23 | 24 | return ( 25 | 26 | setOpen(true)}>Import New Images 27 | setOpen(false)}> 28 | {isOpen && } 29 | 30 | 31 | ); 32 | } 33 | 34 | export interface ImageItem { 35 | id?: any; 36 | url?: string; 37 | imageWidth?: number; 38 | imageHeight?: number; 39 | } 40 | 41 | function ImportDialog() { 42 | const dialog = useDialogContainer(); 43 | const [imageItems, setImageItems] = useState([ 44 | { id: Date.now() }, 45 | ]); 46 | const imageItemsLength = useMemo(() => imageItems.length - 1, [imageItems]); 47 | const addImageItemsToStore = useProjectStore((state) => state.addImages); 48 | 49 | function handleValidImageLoaded( 50 | url: string, 51 | imageWidth: number, 52 | imageHeight: number 53 | ) { 54 | let newImageItems = [...imageItems]; 55 | newImageItems.push({ 56 | id: Date.now() + url, 57 | url, 58 | imageHeight, 59 | imageWidth, 60 | }); 61 | setImageItems(newImageItems); 62 | } 63 | 64 | function handleOnDeleteItem(id: any) { 65 | let newImageItems = imageItems.filter((item) => item.id !== id); 66 | setImageItems(newImageItems); 67 | } 68 | 69 | function handleSaveImportsButtonClick() { 70 | let images = [...imageItems]; 71 | images.shift(); 72 | addImageItemsToStore(images); 73 | dialog.dismiss() 74 | } 75 | 76 | return ( 77 | 78 | Import New Images 79 | 80 | 81 |
82 | {imageItems.map((item) => ( 83 | { 91 | handleValidImageLoaded(url, imageWidth, imageHeight); 92 | }} 93 | onDelete={handleOnDeleteItem} 94 | /> 95 | ))} 96 | 97 |
98 | 99 | 102 | 105 | 106 |
107 | ); 108 | } 109 | 110 | function ImportImageItem({ 111 | id, 112 | onValidImageLoaded, 113 | onDelete, 114 | }: { 115 | id: any; 116 | onValidImageLoaded: ( 117 | url: string, 118 | imageWidth: number, 119 | imageHeight: number 120 | ) => void; 121 | onDelete?: (id: any) => void; 122 | }) { 123 | const [imageUrl, setImageUrl] = useState(""); 124 | const [imageSize, setImageSize] = useState({ width: 0, height: 0 }); 125 | const [isValidUrl, setIsValidUrl] = useState(true); 126 | 127 | const handleChange = (value: string) => { 128 | const url = value; 129 | setImageUrl(url); 130 | setImageSize({ width: 0, height: 0 }); 131 | setIsValidUrl(true); 132 | 133 | if (url) { 134 | const img = new Image(); 135 | img.onload = () => { 136 | const isValid = img.naturalHeight > 1; 137 | setImageSize({ width: img.naturalWidth, height: img.naturalHeight }); 138 | setIsValidUrl(isValid); 139 | 140 | if (isValid) { 141 | onValidImageLoaded(url, img.naturalWidth, img.naturalHeight); 142 | } 143 | }; 144 | img.onerror = () => { 145 | setIsValidUrl(false); 146 | }; 147 | img.src = url; 148 | } 149 | }; 150 | 151 | return ( 152 | <> 153 | {(!isValidUrl || !imageUrl) && ( 154 | <> 155 | 156 | 163 | 171 | {!isValidUrl ? "Invalid Url" : null} 172 | 173 | 174 | )} 175 | {imageUrl && isValidUrl && ( 176 | 177 | 178 | Imported image 183 | 184 | 185 | 186 | 193 | 198 | 199 | 200 | 201 | 205 | 206 | 207 | 218 | 219 | 220 | )} 221 | 222 | ); 223 | } 224 | -------------------------------------------------------------------------------- /src/Editor/Lyrics/LyricPreview/EditableTextInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { Html } from "react-konva-utils"; 3 | import { 4 | DEFAULT_TEXT_PREVIEW_FONT_COLOR, 5 | DEFAULT_TEXT_PREVIEW_FONT_NAME, 6 | DEFAULT_TEXT_PREVIEW_FONT_SIZE, 7 | LyricText, 8 | } from "../../types"; 9 | 10 | function getStyle(width: number, height: number, lyricText: LyricText): any { 11 | const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; 12 | const baseStyle = { 13 | minWidth: `${width}px`, 14 | minHeight: `${height}px`, 15 | border: "none", 16 | padding: "0px", 17 | margin: "0px", 18 | background: "none", 19 | outline: "none", 20 | resize: "none", 21 | color: lyricText.fontColor ?? DEFAULT_TEXT_PREVIEW_FONT_COLOR, 22 | fontSize: lyricText.fontSize ?? DEFAULT_TEXT_PREVIEW_FONT_SIZE, 23 | fontFamily: lyricText.fontName ?? DEFAULT_TEXT_PREVIEW_FONT_NAME, 24 | }; 25 | if (isFirefox) { 26 | return baseStyle; 27 | } 28 | return { 29 | ...baseStyle, 30 | margintop: "-4px", 31 | }; 32 | } 33 | 34 | export function EditableTextInput({ 35 | x, 36 | y, 37 | width, 38 | height, 39 | value, 40 | onChange, 41 | onKeyDown, 42 | }: { 43 | x: number; 44 | y: number; 45 | width: number; 46 | height: number; 47 | value: LyricText; 48 | onChange: (e: any) => void; 49 | onKeyDown: (e: any) => void; 50 | }) { 51 | const ref = useRef(); 52 | 53 | // DOESN'T WORK 54 | // useEffect(() => { 55 | // if (ref.current) { 56 | // ref.current.selectionEnd = 0; 57 | // ref.current.focus(); 58 | // } 59 | // }, [ref]); 60 | 61 | const style = getStyle(width, height, value); 62 | return ( 63 | 64 |