├── .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 |
6 |
7 |
8 | **NEW Feature** Linear time sync mode (Apple Music style)
9 |
10 |
11 | ## Demo
12 | Stephen Sanchez - Until I Found You
13 |
14 | [](https://www.youtube.com/watch?v=To29kD8vPoI)
15 |
16 |
17 | Lyrictor + AI (Preview)
18 |
19 | [](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 |
45 | );
46 | }
47 |
48 | function SettingLabel({ label, isLight }: { label: string; isLight: boolean }) {
49 | return (
50 |
51 |
52 |
58 | {label}
59 |
60 |
61 |
62 | );
63 | }
64 |
65 | export function CustomizationSettingRow({
66 | label,
67 | value,
68 | settingComponent,
69 | }: {
70 | label: string;
71 | value: string;
72 | settingComponent: any;
73 | }) {
74 | return (
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | {settingComponent}
84 |
85 |
86 | );
87 | }
88 |
89 | export function FontSizeSettingRow({
90 | selectedLyricText,
91 | width,
92 | }: {
93 | selectedLyricText: LyricText;
94 | width: any;
95 | }) {
96 | const modifyLyricTexts = useProjectStore((state) => state.modifyLyricTexts);
97 | const [value, setValue] = useState(
98 | selectedLyricText.fontSize ?? DEFAULT_TEXT_PREVIEW_FONT_SIZE
99 | );
100 |
101 | return (
102 | {
112 | setValue(value);
113 | modifyLyricTexts(
114 | TextCustomizationSettingType.fontSize,
115 | [selectedLyricText.id],
116 | value
117 | );
118 | }}
119 | />
120 | }
121 | />
122 | );
123 | }
124 |
125 | const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
126 |
127 | export function FontWeightSettingRow({
128 | selectedLyricText,
129 | }: {
130 | selectedLyricText: LyricText;
131 | }) {
132 | const modifyLyricTexts = useProjectStore((state) => state.modifyLyricTexts);
133 | const [value, setValue] = useState(
134 | selectedLyricText.fontWeight ?? 400
135 | );
136 |
137 | return (
138 | {
146 | setValue(key);
147 | modifyLyricTexts(
148 | TextCustomizationSettingType.fontWeight,
149 | [selectedLyricText.id],
150 | key
151 | );
152 | }}
153 | >
154 | {FONT_WEIGHTS.map((weight) => (
155 | -
156 |
157 | {weight}
158 |
159 |
160 | ))}
161 |
162 | }
163 | />
164 | );
165 | }
166 |
167 | const FONTS = [
168 | "Arial",
169 | "Arial Black",
170 | "Big Shoulders Inline Display Variable",
171 | "Caveat Variable",
172 | "Comfortaa Variable",
173 | "Comic Sans MS",
174 | "Courier New",
175 | "Dancing Script Variable",
176 | "Darker Grotesque Variable",
177 | "Edu NSW ACT Foundation Variable",
178 | "Georgia",
179 | "Impact",
180 | "Inter Variable",
181 | "Merienda Variable",
182 | "Montserrat Variable",
183 | "Open Sans Variable",
184 | "Red Hat Display Variable",
185 | "Roboto Mono Variable",
186 | "Times New Roman",
187 | "Trebuchet MS",
188 | "Verdana",
189 | ];
190 |
191 | export function FontSettingRow({
192 | selectedLyricText,
193 | }: {
194 | selectedLyricText: LyricText;
195 | }) {
196 | const modifyLyricTexts = useProjectStore((state) => state.modifyLyricTexts);
197 | const [value, setValue] = useState(
198 | selectedLyricText.fontName ?? DEFAULT_TEXT_PREVIEW_FONT_NAME
199 | );
200 |
201 | return (
202 | {
209 | setValue(key);
210 | modifyLyricTexts(
211 | TextCustomizationSettingType.fontName,
212 | [selectedLyricText.id],
213 | key
214 | );
215 | }}
216 | >
217 | {FONTS.map((font) => (
218 | -
219 |
220 | {font}
221 |
222 |
223 | ))}
224 |
225 | }
226 | />
227 | );
228 | }
229 |
230 | export function ShadowBlurSettingRow({
231 | selectedLyricText,
232 | selectedLyricTextIds,
233 | width,
234 | }: {
235 | selectedLyricText?: LyricText;
236 | selectedLyricTextIds?: number[];
237 | width: any;
238 | }) {
239 | const modifyLyricTexts = useProjectStore((state) => state.modifyLyricTexts);
240 | const [value, setValue] = useState(
241 | selectedLyricText?.shadowBlur ?? 0
242 | );
243 |
244 | const ids = useMemo(() => {
245 | if (selectedLyricText) {
246 | return [selectedLyricText.id];
247 | } else if (selectedLyricTextIds) {
248 | return selectedLyricTextIds;
249 | }
250 |
251 | return undefined;
252 | }, [selectedLyricTextIds, selectedLyricText]);
253 |
254 | return (
255 | {
266 | if (ids) {
267 | setValue(value);
268 | modifyLyricTexts(
269 | TextCustomizationSettingType.shadowBlur,
270 | ids,
271 | value
272 | );
273 | }
274 | }}
275 | />
276 | }
277 | />
278 | );
279 | }
280 |
281 | export function ShadowBlurColorSettingRow({
282 | selectedLyricText,
283 | width,
284 | }: {
285 | selectedLyricText: LyricText;
286 | width: any;
287 | }) {
288 | const modifyLyricTexts = useProjectStore((state) => state.modifyLyricTexts);
289 | const [value, setValue] = useState(
290 | selectedLyricText.shadowColor ?? { r: 0, g: 0, b: 0 }
291 | );
292 |
293 | function handleColorChange(color: ColorResult) {
294 | setValue(color.rgb);
295 | modifyLyricTexts(
296 | TextCustomizationSettingType.shadowColor,
297 | [selectedLyricText.id],
298 | color.rgb
299 | );
300 | }
301 |
302 | function handleColorChangeComplete(color: ColorResult) {
303 | // Optionally used for updates after the color picker is closed or interaction is finished.
304 | }
305 |
306 | return (
307 |
313 | );
314 | }
315 |
316 | export function FontColorSettingRow({
317 | selectedLyricText,
318 | width,
319 | }: {
320 | selectedLyricText: LyricText;
321 | width: any;
322 | }) {
323 | const modifyLyricTexts = useProjectStore((state) => state.modifyLyricTexts);
324 | const [color, setColor] = useState(
325 | selectedLyricText.fontColor ?? { r: 255, g: 255, b: 255 }
326 | );
327 |
328 | function handleColorChange(color: ColorResult) {
329 | console.log(color);
330 | setColor(color.rgb);
331 | modifyLyricTexts(
332 | TextCustomizationSettingType.fontColor,
333 | [selectedLyricText.id],
334 | color.rgb
335 | );
336 | }
337 |
338 | function handleColorChangeComplete(color: ColorResult) {
339 | // Optionally used for updates after the color picker is closed or interaction is finished.
340 | }
341 |
342 | return (
343 |
349 | );
350 | }
351 |
352 | interface ColorPickerComponentProps {
353 | color: RGBColor;
354 | onChange: (color: ColorResult) => void;
355 | onChangeComplete?: (color: ColorResult) => void;
356 | label: string;
357 | hideLabel?: boolean;
358 | }
359 |
360 | export function ColorPickerComponent({
361 | color,
362 | onChange,
363 | onChangeComplete,
364 | label,
365 | hideLabel,
366 | }: ColorPickerComponentProps) {
367 | const [isColorPickerVisible, setIsColorPickerVisible] = useState(false);
368 | const divRef = useRef(null);
369 | const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 });
370 |
371 | function handleCurrentColorClick() {
372 | const current = divRef.current;
373 | if (current) {
374 | const rect = current.getBoundingClientRect();
375 | setPickerPosition({ top: rect.bottom, left: rect.left });
376 | }
377 | setIsColorPickerVisible(!isColorPickerVisible);
378 | }
379 |
380 | // TODO: Improve color picker appear location
381 | const picker = (
382 | setIsColorPickerVisible(false)}>
383 |
384 |
399 | {isColorPickerVisible ? (
400 |
410 |
415 |
416 | ) : null}
417 |
418 |
419 | );
420 |
421 | if (hideLabel) {
422 | return picker;
423 | }
424 |
425 | return (
426 |
431 | );
432 | }
433 |
434 | export function rgbToRgbaString(color: RGBColor): string {
435 | const { r, g, b, a } = color;
436 | if (a !== undefined) {
437 | return `rgba(${r}, ${g}, ${b}, ${a})`;
438 | }
439 | return `rgba(${r}, ${g}, ${b}, 1)`;
440 | }
441 |
--------------------------------------------------------------------------------
/src/Editor/AudioTimeline/Tools/FullScreenButton.tsx:
--------------------------------------------------------------------------------
1 | import Maximize from "@spectrum-icons/workflow/Maximize";
2 | import { ActionButton } from "@adobe/react-spectrum";
3 |
4 | export default function FullScreenButton() {
5 | return (
6 | toggle_full_screen()}
10 | >
11 |
12 |
13 | );
14 | }
15 |
16 | function toggle_full_screen() {
17 | const documentAny = document as any;
18 | const elementAny = Element as any;
19 | if (
20 | (documentAny.fullScreenElement && documentAny.fullScreenElement !== null) ||
21 | (!documentAny.mozFullScreen && !documentAny.webkitIsFullScreen)
22 | ) {
23 | if (documentAny.documentElement.requestFullScreen) {
24 | documentAny.documentElement.requestFullScreen();
25 | } else if (documentAny.documentElement.mozRequestFullScreen) {
26 | /* Firefox */
27 | documentAny.documentElement.mozRequestFullScreen();
28 | } else if (documentAny.documentElement.webkitRequestFullScreen) {
29 | /* Chrome, Safari & Opera */
30 | documentAny.documentElement.webkitRequestFullScreen(
31 | elementAny.ALLOW_KEYBOARD_INPUT
32 | );
33 | } else if (documentAny.msRequestFullscreen) {
34 | /* IE/Edge */
35 | documentAny.documentElement.msRequestFullscreen();
36 | }
37 | } else {
38 | if (documentAny.cancelFullScreen) {
39 | documentAny.cancelFullScreen();
40 | } else if (documentAny.mozCancelFullScreen) {
41 | /* Firefox */
42 | documentAny.mozCancelFullScreen();
43 | } else if (documentAny.webkitCancelFullScreen) {
44 | /* Chrome, Safari and Opera */
45 | documentAny.webkitCancelFullScreen();
46 | } else if (documentAny.msExitFullscreen) {
47 | /* IE/Edge */
48 | documentAny.msExitFullscreen();
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Editor/AudioTimeline/Tools/LyricTextCustomizationToolPanel.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, View, Well, Text } from "@adobe/react-spectrum";
2 | import { useMemo } from "react";
3 | import { useProjectStore } from "../../../Project/store";
4 | import { useEditorStore } from "../../store";
5 | import {
6 | FontColorSettingRow,
7 | FontSettingRow,
8 | FontSizeSettingRow,
9 | FontWeightSettingRow,
10 | ShadowBlurColorSettingRow,
11 | ShadowBlurSettingRow,
12 | TextReferenceTextAreaRow,
13 | } from "./CustomizationSettingRow";
14 | import Alert from "@spectrum-icons/workflow/Alert";
15 |
16 | export const CUSTOMIZATION_PANEL_WIDTH = 200;
17 | const HEADER_HEIGHT = 25;
18 |
19 | export default function LyricTextCustomizationToolPanel({
20 | height,
21 | width,
22 | }: {
23 | height: any;
24 | width: any;
25 | }) {
26 | const lyricTexts = useProjectStore((state) => state.lyricTexts);
27 | const selectedLyricTextIds = useEditorStore(
28 | (state) => state.selectedLyricTextIds
29 | );
30 | const selectedLyricTextIdArray = useMemo(
31 | () => Array.from(selectedLyricTextIds),
32 | [selectedLyricTextIds]
33 | );
34 |
35 | const selectedLyricText = useMemo(() => {
36 | const isSingleSelection = selectedLyricTextIds.size === 1;
37 | const selectedFromTimeline = isSingleSelection
38 | ? lyricTexts.find((lyricText) => selectedLyricTextIds.has(lyricText.id))
39 | : undefined;
40 |
41 | return selectedFromTimeline;
42 | }, [selectedLyricTextIds]);
43 |
44 | const isMultipleSelected = useMemo(
45 | () => selectedLyricTextIds.size > 1,
46 | [selectedLyricTextIds]
47 | );
48 |
49 | return (
50 |
51 | {!isMultipleSelected && selectedLyricText?.text ? (
52 |
58 |
59 |
60 |
64 |
68 |
69 |
70 |
74 |
78 |
79 |
80 | ) : isMultipleSelected ? (
81 |
86 |
87 |
88 |
89 |
90 | The settings below will be applied to all{" "}
91 |
92 | {selectedLyricTextIds.size}
93 | {" "}
94 | selected lyric texts
95 |
96 |
97 |
98 |
102 |
103 |
104 | ) : (
105 |
112 | No lyric text selected
113 |
114 | )}
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/src/Editor/AudioTimeline/Tools/ToolsView.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Slider, View } from "@adobe/react-spectrum";
2 | import formatDuration from "format-duration";
3 | import GenerateAIImageButton from "../../Image/AI/GenerateAIImageButton";
4 | import PlayPauseButton from "../PlayBackControls";
5 | import EditDropDownMenu, { EditOptionType } from "../../EditDropDownMenu";
6 | import AddVisualizerButton from "./AddVisualizerButton";
7 | import AddLyricTextButton from "./AddLyricTextButton";
8 |
9 | export function ToolsView({
10 | playing,
11 | togglePlayPause,
12 | percentComplete,
13 | duration,
14 | position,
15 | zoomStep,
16 | zoomAmount,
17 | initWidth,
18 | currentWidth,
19 | windowWidth,
20 | calculateScrollbarLength,
21 | setWidth,
22 | onItemClick,
23 | }: {
24 | playing: boolean;
25 | togglePlayPause: () => void;
26 | percentComplete: number;
27 | duration: number;
28 | position: number;
29 | zoomStep: number;
30 | zoomAmount: number;
31 | initWidth: number;
32 | currentWidth: number;
33 | windowWidth: number | undefined;
34 | calculateScrollbarLength: () => number;
35 | setWidth: (newWidth: number) => void;
36 | onItemClick: (option: EditOptionType) => void;
37 | }) {
38 | return (
39 |
40 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
68 | {
71 | togglePlayPause();
72 | }}
73 | />
74 |
75 |
81 |
82 | {formatDuration((percentComplete / 100) * duration * 1000)}
83 |
84 | /
85 |
86 | {formatDuration(duration * 1000)}{" "}
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | {
105 | const newWidth: number = initWidth + initWidth * value;
106 | const scrollableArea: number =
107 | windowWidth! - calculateScrollbarLength();
108 | const isZoomIn: boolean = newWidth > currentWidth;
109 | let velocity: number;
110 |
111 | setWidth(newWidth);
112 | }}
113 | isFilled
114 | />
115 |
116 | {/*
117 |
118 | */}
119 |
120 |
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/src/Editor/AudioTimeline/Tools/types.ts:
--------------------------------------------------------------------------------
1 | export enum TextCustomizationSettingType {
2 | fontSize = "fontSize",
3 | fontWeight = "fontWeight",
4 | fontName = "fontName",
5 | fontColor = "fontColor",
6 | shadowBlur = "shadowBlur",
7 | shadowColor = "shadowColor",
8 | text = "text"
9 | }
10 |
11 | export interface TextCustomizationSetting {
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/src/Editor/AudioTimeline/store.ts:
--------------------------------------------------------------------------------
1 | export interface AudioTimelineStore {
2 |
3 | }
--------------------------------------------------------------------------------
/src/Editor/AudioTimeline/utils.ts:
--------------------------------------------------------------------------------
1 | export function getVisibleSongRange({
2 | width,
3 | windowWidth,
4 | duration,
5 | scrollXOffSet,
6 | }: {
7 | width: number;
8 | windowWidth: number | undefined;
9 | duration: number;
10 | scrollXOffSet: number;
11 | }): number[] {
12 | const from = (Math.abs(scrollXOffSet) / width) * duration;
13 | const to =
14 | ((Math.abs(scrollXOffSet) + (windowWidth ?? 0)) / width) * duration;
15 | return [from, to];
16 | }
17 |
--------------------------------------------------------------------------------
/src/Editor/EditDropDownMenu.tsx:
--------------------------------------------------------------------------------
1 | import { MenuTrigger, ActionButton, Menu, Item } from "@adobe/react-spectrum";
2 | import ChevronDown from "@spectrum-icons/workflow/ChevronDown";
3 | import { Keyboard, Text } from "@adobe/react-spectrum";
4 | import Copy from "@spectrum-icons/workflow/Copy";
5 | import Paste from "@spectrum-icons/workflow/Paste";
6 | import DeleteIcon from "@spectrum-icons/workflow/Delete";
7 | import UndoIcon from "@spectrum-icons/workflow/Undo";
8 |
9 | export type EditOptionType = "delete" | "undo" | "copy" | "paste";
10 |
11 | const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
12 |
13 | export default function EditDropDownMenu({
14 | onItemClick,
15 | }: {
16 | onItemClick: (option: EditOptionType) => void;
17 | }) {
18 | const getKeyboardShortcut = (action: EditOptionType) => {
19 | if (isMac) {
20 | switch (action) {
21 | case "delete":
22 | return "←"; // or "⌫" if you prefer using the Backspace key
23 | case "undo":
24 | return "⌘Z";
25 | case "copy":
26 | return "⌘C";
27 | case "paste":
28 | return "⌘V";
29 | default:
30 | return "";
31 | }
32 | } else {
33 | switch (action) {
34 | case "delete":
35 | return "←"; // or "Backspace" if you prefer
36 | case "undo":
37 | return "Ctrl+Z";
38 | case "copy":
39 | return "Ctrl+C";
40 | case "paste":
41 | return "Ctrl+V";
42 | default:
43 | return "";
44 | }
45 | }
46 | };
47 |
48 | return (
49 |
50 |
57 | Edit
58 |
62 |
63 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/Editor/Image/AI/AIImageGenerator.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | ProgressCircle,
4 | View,
5 | Text,
6 | Flex,
7 | Grid,
8 | Divider,
9 | TextField,
10 | Well,
11 | Link,
12 | } from "@adobe/react-spectrum";
13 | import { useEffect, useMemo } from "react";
14 | import AIImageGeneratorError from "./AIImageGeneratorError";
15 | import GenerateImagesLog from "./GenerateImagesLog";
16 | import PromptLogButton from "./PromptLogButton";
17 | import { getImageFileUrl, useAIImageGeneratorStore } from "./store";
18 | import { PredictParams, PromptParamsType } from "./types";
19 | import { useAIImageService } from "./useAIImageService";
20 |
21 | export default function AIImageGenerator() {
22 | const [generateImage, isLoading, checkIfLocalAIRunning, isLocalAIRunning] =
23 | useAIImageService(true);
24 | const setCurrentGenFileUrl = useAIImageGeneratorStore(
25 | (state) => state.setCurrentGenFileUrl
26 | );
27 | const currentGenFileUrl = useAIImageGeneratorStore(
28 | (state) => state.currentGenFileUrl
29 | );
30 | const currentGenParams = useAIImageGeneratorStore(
31 | (state) => state.currentGenParams
32 | );
33 | const setCurrentGenParams = useAIImageGeneratorStore(
34 | (state) => state.setCurrentGenParams
35 | );
36 | const prompt = useAIImageGeneratorStore((state) => state.prompt);
37 | const updatePrompt = useAIImageGeneratorStore((state) => state.updatePrompt);
38 | const logPrompt = useAIImageGeneratorStore((state) => state.logPrompt);
39 | const logGenerateImage = useAIImageGeneratorStore(
40 | (state) => state.logGeneratedImage
41 | );
42 | const selectedImageLogItem = useAIImageGeneratorStore(
43 | (state) => state.selectedImageLogItem
44 | );
45 | const setSelectedImageLogItem = useAIImageGeneratorStore(
46 | (state) => state.setSelectedImageLogTiem
47 | );
48 |
49 | const isGenerateEnabled: boolean = useMemo(() => {
50 | return Boolean(prompt.prompt);
51 | }, [prompt]);
52 |
53 | useEffect(() => {
54 | checkIfLocalAIRunning();
55 | }, []);
56 |
57 | async function onGeneratePress() {
58 | const resp = await generateImage(prompt);
59 | const name = resp.data[0][0].name;
60 | setCurrentGenFileUrl(name);
61 | const genPrompt = resp.data[1] as PredictParams;
62 | setCurrentGenParams(genPrompt);
63 | logPrompt(genPrompt);
64 | logGenerateImage({ url: getImageFileUrl(name), prompt: genPrompt });
65 |
66 | setSelectedImageLogItem({ url: getImageFileUrl(name), prompt: genPrompt });
67 | }
68 |
69 | function handleSeedFieldChange(value: string) {
70 | updatePrompt(
71 | PromptParamsType.seed,
72 | Number(value) === 0 ? -1 : Number(value)
73 | );
74 | }
75 |
76 | if (!isLocalAIRunning) {
77 | return ;
78 | }
79 |
80 | return (
81 |
82 |
89 |
97 |
98 |
99 |
100 |
104 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/Editor/Lyrics/LyricPreview/LinearTimeSyncedLyricPreview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useRef, useMemo } from "react";
2 | import { motion } from "framer-motion";
3 | import { LyricText } from "../../types";
4 | import { getCurrentLyricIndex } from "../../utils";
5 |
6 | const SCROLL_DURATION = 0.75;
7 |
8 | export function TimeSyncedLyrics({
9 | width,
10 | height,
11 | position,
12 | lyricTexts,
13 | }: {
14 | width: number;
15 | height: number;
16 | position: number;
17 | lyricTexts: LyricText[];
18 | }) {
19 | const currentLyricIndex: number | undefined = useMemo(
20 | () => getCurrentLyricIndex(lyricTexts, position),
21 | [lyricTexts, position]
22 | );
23 | const [lyricHeights, setLyricHeights] = useState<{ [key: number]: number }>(
24 | {}
25 | );
26 | const [currentScrollHeight, setCurrentScrollHeight] = useState(0);
27 | const containerRef = useRef(null);
28 | const lyricRefs = useRef<(HTMLDivElement | null)[]>([]);
29 |
30 | useEffect(() => {
31 | if (currentLyricIndex !== undefined) {
32 | setCurrentScrollHeight(getCumulativeHeight(currentLyricIndex));
33 | }
34 | }, [currentLyricIndex]);
35 |
36 | useEffect(() => {
37 | if (lyricRefs.current.length > 0) {
38 | const newHeights: { [key: number]: number } = {};
39 | lyricRefs.current.forEach((lyricRef, index) => {
40 | if (lyricRef) {
41 | newHeights[index] = lyricRef.getBoundingClientRect().height;
42 | }
43 | });
44 | setLyricHeights(newHeights);
45 | }
46 | }, [lyricTexts, width, height]);
47 |
48 | const getCumulativeHeight = (index: number) => {
49 | let totalHeight = 0;
50 | for (let i = 0; i < index; i++) {
51 | totalHeight += lyricHeights[i] || 0;
52 | totalHeight += 20;
53 | }
54 | return totalHeight;
55 | };
56 |
57 | function calculateFontSize(width: number, height: number): number {
58 | return Math.min(width, height) * 0.067;
59 | }
60 |
61 | function calculatePadding(width: number, height: number): number {
62 | return Math.min(width, height) * 0.025;
63 | }
64 |
65 | return (
66 |
75 |
84 |
89 | {lyricTexts.map((lyric, index) => (
90 | (lyricRefs.current[index] = el)}
93 | style={{
94 | fontFamily: "Inter Variable",
95 | padding: calculatePadding(width, height),
96 | fontSize: calculateFontSize(width, height) + "px",
97 | fontWeight: "bolder",
98 | backgroundColor: "transparent",
99 | marginBottom: "20px",
100 | }}
101 | animate={{
102 | color:
103 | currentLyricIndex === index
104 | ? "rgba(255, 255, 255, 1)"
105 | : "rgba(255, 255, 255, 0.35)",
106 | }}
107 | transition={{ duration: 0.5 }}
108 | >
109 | {lyric.text}
110 |
111 | ))}
112 |
113 |
114 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/src/Editor/Lyrics/LyricPreview/LyricsTextView.tsx:
--------------------------------------------------------------------------------
1 | import { KonvaEventObject } from "konva/lib/Node";
2 | import { Text as KonvaText } from "react-konva";
3 | import { useEffect, useState } from "react";
4 | import { useEditorStore } from "../../store";
5 | import {
6 | DEFAULT_TEXT_PREVIEW_HEIGHT,
7 | DEFAULT_TEXT_PREVIEW_WIDTH,
8 | LyricText,
9 | } from "../../types";
10 | import { EditableTextInput } from "./EditableTextInput";
11 | import { ResizableText } from "./ResizableText";
12 | import { rgbToRgbaString } from "../../AudioTimeline/Tools/CustomizationSettingRow";
13 |
14 | const RETURN_KEY = 13;
15 | const ESCAPE_KEY = 27;
16 |
17 | export interface LyricsTextViewProps
18 | extends React.ComponentProps {
19 | x: number;
20 | y: number;
21 | onEscapeKeysPressed: (lyricText: LyricText) => void;
22 | onResize: (newWidth: number, newHeight: number) => void;
23 | onDragStart: (evt: KonvaEventObject) => void;
24 | onDragEnd: (evt: KonvaEventObject) => void;
25 | onDragMove: (evt: KonvaEventObject) => void;
26 | lyricText: LyricText;
27 | width: number | undefined;
28 | height: number | undefined;
29 | previewWindowWidth: number;
30 | previewWindowHeight: number;
31 | isEditMode?: boolean;
32 | }
33 |
34 | export function LyricsTextView({
35 | x,
36 | y,
37 | onEscapeKeysPressed,
38 | onResize,
39 | onDragStart,
40 | onDragEnd,
41 | onDragMove,
42 | lyricText,
43 | width,
44 | height,
45 | previewWindowWidth,
46 | previewWindowHeight,
47 | isEditMode = true,
48 | }: LyricsTextViewProps) {
49 | const selectedTimelineLyricTextIds = useEditorStore(
50 | (state) => state.selectedLyricTextIds
51 | );
52 | const setSelectedTimelineTextIds = useEditorStore(
53 | (state) => state.setSelectedLyricTextIds
54 | );
55 | const toggleCustomizationPanelState = useEditorStore(
56 | (state) => state.toggleCustomizationPanelOpenState
57 | );
58 | const [isEditing, setIsEditing] = useState(false);
59 | const [editingTextWidth, setEditingTextWidth] = useState<
60 | number | undefined
61 | >();
62 | const [editingTextHeight, setEditingTextHeight] = useState<
63 | number | undefined
64 | >();
65 | const editingText = useEditorStore((state) => state.editingText);
66 | const setEditingText = useEditorStore((state) => state.setEditingText);
67 |
68 | useEffect(() => {
69 | if (isEditing) {
70 | setEditingText(lyricText);
71 | }
72 | }, [isEditing]);
73 |
74 | useEffect(() => {
75 | if (editingText && editingText.id !== lyricText.id) {
76 | setIsEditing(false);
77 | }
78 | }, [editingText]);
79 |
80 | function handleEscapeKeys(e: any) {
81 | if ((e.keyCode === RETURN_KEY && !e.shiftKey) || e.keyCode === ESCAPE_KEY) {
82 | setIsEditing(!isEditing);
83 |
84 | if (editingText) {
85 | onEscapeKeysPressed(editingText);
86 | }
87 | }
88 | }
89 |
90 | function handleTextChange(e: any) {
91 | if (editingText) {
92 | setEditingText({ ...lyricText, text: e.currentTarget.value });
93 | }
94 | }
95 |
96 | function handleDoubleClick(e: any) {
97 | if (isEditMode) {
98 | setEditingTextWidth(e.target.textWidth);
99 | setEditingTextHeight(e.target.textHeight);
100 | setIsEditing(!isEditing);
101 | }
102 | }
103 |
104 | if (editingText && editingText.id === lyricText.id) {
105 | return (
106 |
120 | );
121 | }
122 |
123 | return (
124 | {
130 | if (isEditMode) {
131 | setSelectedTimelineTextIds(new Set([lyricText.id]));
132 | toggleCustomizationPanelState(true);
133 | }
134 | }}
135 | onDoubleClick={handleDoubleClick}
136 | onResize={onResize}
137 | lyricText={{
138 | ...lyricText,
139 | fontSize:
140 | (lyricText.fontSize ? lyricText.fontSize / 1000 : 0.02) *
141 | previewWindowWidth,
142 | }}
143 | width={isEditing ? editingTextWidth : width}
144 | onDragStart={onDragStart}
145 | onDragEnd={onDragEnd}
146 | onDragMove={onDragMove}
147 | fill={
148 | lyricText.fontColor ? rgbToRgbaString(lyricText.fontColor) : "white"
149 | }
150 | shadowBlur={lyricText.shadowBlur}
151 | shadowColor={
152 | lyricText.shadowColor
153 | ? rgbToRgbaString(lyricText.shadowColor)
154 | : undefined
155 | }
156 | />
157 | );
158 | }
159 |
--------------------------------------------------------------------------------
/src/Editor/Lyrics/LyricPreview/PreviewWindowAlignGuide.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Layer, Line } from "react-konva";
3 |
4 | type PreviewWindowAlignGuideProps = {
5 | previewWidth: number;
6 | previewHeight: number;
7 | boxWidth: number;
8 | boxHeight: number;
9 | boxX: number;
10 | boxY: number;
11 | };
12 |
13 | export default function PreviewWindowAlignGuide({
14 | previewWidth,
15 | previewHeight,
16 | boxWidth,
17 | boxHeight,
18 | boxX,
19 | boxY,
20 | }: PreviewWindowAlignGuideProps) {
21 | const [showVerticalGuide, setShowVerticalGuide] = useState(false);
22 | const [showHorizontalGuide, setShowHorizontalGuide] =
23 | useState(false);
24 |
25 | const centerX = previewWidth / 2;
26 | const centerY = previewHeight / 2;
27 |
28 | useEffect(() => {
29 | // Vertical proximity checks: center, left edge, and right edge of the box
30 | const isNearCenterX = Math.abs(boxX + boxWidth / 2 - centerX) <= 5;
31 | const isNearLeftEdge = Math.abs(boxX - centerX) <= 5;
32 | const isNearRightEdge = Math.abs(boxX + boxWidth - centerX) <= 5;
33 |
34 | // Horizontal proximity checks: center, top edge, and bottom edge of the box
35 | const isNearCenterY = Math.abs(boxY + boxHeight / 2 - centerY) <= 5;
36 | const isNearTopEdge = Math.abs(boxY - centerY) <= 5;
37 | const isNearBottomEdge = Math.abs(boxY + boxHeight - centerY) <= 5;
38 |
39 | // Update states based on any edge being near the center
40 | setShowVerticalGuide(isNearCenterX || isNearLeftEdge || isNearRightEdge);
41 | setShowHorizontalGuide(isNearCenterY || isNearTopEdge || isNearBottomEdge);
42 | }, [boxX, boxY, boxWidth, boxHeight, previewWidth, previewHeight]);
43 |
44 | return (
45 |
46 | {showVerticalGuide && (
47 |
52 | )}
53 | {showHorizontalGuide && (
54 |
59 | )}
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/Editor/Lyrics/LyricPreview/ResizableText.tsx:
--------------------------------------------------------------------------------
1 | import { KonvaEventObject } from "konva/lib/Node";
2 | import { useRef, useEffect } from "react";
3 | import { Text, Transformer } from "react-konva";
4 | import {
5 | DEFAULT_TEXT_PREVIEW_FONT_COLOR,
6 | DEFAULT_TEXT_PREVIEW_FONT_NAME,
7 | DEFAULT_TEXT_PREVIEW_FONT_SIZE,
8 | LyricText,
9 | } from "../../types";
10 | import { rgbToRgbaString } from "../../AudioTimeline/Tools/CustomizationSettingRow";
11 |
12 | export interface ResizableTextProps extends React.ComponentProps {
13 | x: number;
14 | y: number;
15 | lyricText: LyricText;
16 | isSelected: boolean;
17 | width: number | undefined;
18 | onResize: (newWidth: number, newHeight: number) => void;
19 | onClick: () => void;
20 | onDoubleClick: (e: any) => void;
21 | onDragStart: (evt: KonvaEventObject) => void;
22 | onDragEnd: (evt: KonvaEventObject) => void;
23 | onDragMove: (evt: KonvaEventObject) => void;
24 | isEditMode?: boolean;
25 | }
26 |
27 | export function ResizableText({
28 | x,
29 | y,
30 | lyricText,
31 | isSelected,
32 | width,
33 | onResize,
34 | onClick,
35 | onDoubleClick,
36 | onDragStart,
37 | onDragEnd,
38 | onDragMove,
39 | isEditMode = true,
40 | ...rest
41 | }: ResizableTextProps) {
42 | const textRef = useRef(null);
43 | const transformerRef = useRef(null);
44 |
45 | useEffect(() => {
46 | if (isSelected && transformerRef.current !== null) {
47 | const refCurrent = transformerRef.current as any;
48 | refCurrent.nodes([textRef.current]);
49 | refCurrent.getLayer().batchDraw();
50 | }
51 | }, [isSelected]);
52 |
53 | function handleResize() {
54 | if (textRef.current !== null) {
55 | const textNode = textRef.current as any;
56 | const newWidth = textNode.width() * textNode.scaleX();
57 | const newHeight = textNode.height() * textNode.scaleY();
58 | textNode.setAttrs({
59 | width: newWidth,
60 | scaleX: 1,
61 | });
62 | onResize(newWidth, newHeight);
63 | }
64 | }
65 |
66 | const transformer = isSelected ? (
67 | {
73 | return newBox;
74 | }}
75 | />
76 | ) : null;
77 |
78 | return (
79 | <>
80 |
106 | {transformer}
107 | >
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/src/Editor/Lyrics/LyricReferenceView.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useRef, useCallback } from "react";
2 | import {
3 | Editor,
4 | EditorState,
5 | convertFromRaw,
6 | convertToRaw,
7 | RawDraftContentState,
8 | } from "draft-js";
9 | import "./LyricsView.css";
10 | import { useProjectStore } from "../../Project/store";
11 | import AddLyricTextButton from "../AudioTimeline/Tools/AddLyricTextButton";
12 | import { useAudioPosition } from "react-use-audio-player";
13 |
14 | const useDebounce = (callback: Function, delay: number) => {
15 | const timer = useRef(null);
16 |
17 | return useCallback(
18 | (...args: any[]) => {
19 | if (timer.current) {
20 | clearTimeout(timer.current);
21 | }
22 | timer.current = setTimeout(() => {
23 | callback(...args);
24 | }, delay);
25 | },
26 | [callback, delay]
27 | );
28 | };
29 |
30 | export default function LyricReferenceView() {
31 | const lyricReference = useProjectStore((state) => state.lyricReference);
32 | const setUnSavedLyricReference = useProjectStore(
33 | (state) => state.setUnsavedLyricReference
34 | );
35 | const [editorState, setEditorState] = useState(
36 | EditorState.createEmpty()
37 | );
38 | const [showButton, setShowButton] = useState(false);
39 | const [buttonPosition, setButtonPosition] = useState({ top: 0, left: 0 });
40 | const [selectedText, setSelectedText] = useState(""); // Add state variable to store selected text
41 |
42 | const editorContainer = useRef(null);
43 | const editor = useRef(null);
44 |
45 | const { position } = useAudioPosition({
46 | highRefreshRate: false,
47 | });
48 |
49 | function focusEditor() {
50 | if (editor.current !== null) {
51 | editor.current.focus();
52 | }
53 | }
54 |
55 | useEffect(() => {
56 | if (lyricReference) {
57 | setEditorState(
58 | EditorState.createWithContent(
59 | convertFromRaw(JSON.parse(lyricReference) as RawDraftContentState)
60 | )
61 | );
62 | } else {
63 | setEditorState(EditorState.createEmpty());
64 | }
65 | }, [lyricReference]);
66 |
67 | const handleEditorChange = (editorState: EditorState) => {
68 | setEditorState(editorState);
69 | setUnSavedLyricReference(
70 | JSON.stringify(convertToRaw(editorState.getCurrentContent()))
71 | );
72 | debouncedUpdateButtonPosition(editorState);
73 | };
74 |
75 | const debouncedUpdateButtonPosition = useDebounce(
76 | (editorState: EditorState) => {
77 | const selectionState = editorState.getSelection();
78 | const anchorKey = selectionState.getAnchorKey();
79 | const currentContent = editorState.getCurrentContent();
80 | const currentBlock = currentContent.getBlockForKey(anchorKey);
81 | const blockText = currentBlock.getText();
82 | const selectedText = blockText.slice(
83 | selectionState.getStartOffset(),
84 | selectionState.getEndOffset()
85 | );
86 |
87 | setSelectedText(selectedText); // Store the selected text in state
88 |
89 | if (selectedText) {
90 | const selectionCoords = getSelectionCoords();
91 | if (selectionCoords) {
92 | setButtonPosition(selectionCoords);
93 | setShowButton(true);
94 | }
95 | } else {
96 | setShowButton(false);
97 | }
98 | },
99 | 100
100 | );
101 |
102 | const getSelectionCoords = () => {
103 | const selection = window.getSelection();
104 | if (!selection || selection.rangeCount === 0) {
105 | return null;
106 | }
107 | const range = selection.getRangeAt(0);
108 | const rect = range.getBoundingClientRect();
109 | const editorRect = editorContainer.current?.getBoundingClientRect();
110 | if (!editorRect) {
111 | return null;
112 | }
113 |
114 | if (editorContainer.current) {
115 | return {
116 | top: rect.top - editorRect.top + editorContainer.current.scrollTop,
117 | left:
118 | rect.right -
119 | editorRect.left +
120 | editorContainer.current.scrollLeft +
121 | 10,
122 | };
123 | }
124 | };
125 |
126 | return (
127 |
132 |
138 | {showButton && (
139 |
148 | )}
149 |
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/src/Editor/Lyrics/LyricsView.css:
--------------------------------------------------------------------------------
1 | div.DraftEditor-root {
2 | /* background-color: #252525; */
3 | /* width:80%; */
4 | margin: auto;
5 | max-height: inherit;
6 | overflow-y: auto;
7 | padding: 20px;
8 | font-size: 18px;
9 | /* font-family: "calibri", sans-serif; */
10 | text-align: left;
11 | font-size: 12pt;
12 | }
13 |
--------------------------------------------------------------------------------
/src/Editor/MediaContentSidePanel.tsx:
--------------------------------------------------------------------------------
1 | import { TabList, Item, Tabs, View } from "@adobe/react-spectrum";
2 | import { useProjectStore } from "../Project/store";
3 | import LyricReferenceView from "./Lyrics/LyricReferenceView";
4 | import { useState } from "react";
5 | import Images from "@spectrum-icons/workflow/Images";
6 | import Note from "@spectrum-icons/workflow/Note";
7 | import ImagesManagerView from "./Image/Imported/ImagesManagerView";
8 |
9 | export default function MediaContentSidePanel({
10 | maxRowHeight,
11 | containerWidth,
12 | }: {
13 | maxRowHeight: number;
14 | containerWidth: number;
15 | }) {
16 | const editingProject = useProjectStore((state) => state.editingProject);
17 | const lyricReference = useProjectStore((state) => state.lyricReference);
18 | const [tabId, setTabId] = useState<"lyrics" | "images">("lyrics");
19 |
20 | return (
21 |
22 |
23 | {
26 | setTabId(key);
27 | }}
28 | selectedKey={tabId}
29 | >
30 |
37 | -
38 |
39 |
40 |
41 | Lyrics
42 |
43 | -
44 |
45 |
46 |
47 | Images
48 |
49 |
50 |
51 |
52 |
53 | {tabId === "lyrics" && lyricReference !== undefined ? (
54 |
55 | ) : null}
56 |
57 | {tabId === "images" ? (
58 |
59 | ) : null}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/Editor/SettingsSidePanel.tsx:
--------------------------------------------------------------------------------
1 | import { TabList, Item, Tabs, View, Flex } from "@adobe/react-spectrum";
2 | import LyricTextCustomizationToolPanel from "./AudioTimeline/Tools/LyricTextCustomizationToolPanel";
3 | import AudioVisualizerSettings from "./Visualizer/AudioVisualizerSettings";
4 | import { useEditorStore } from "./store";
5 |
6 | export default function SettingsSidePanel({
7 | maxRowHeight,
8 | containerWidth,
9 | }: {
10 | maxRowHeight: number;
11 | containerWidth: number;
12 | }) {
13 | const tabId = useEditorStore((state) => state.customizationPanelTabId);
14 | const setTabId = useEditorStore((state) => state.setCustomizationPanelTabId);
15 |
16 | return (
17 |
18 |
19 | {
22 | setTabId(key);
23 | }}
24 | selectedKey={tabId}
25 | >
26 |
29 | - Text
30 | - Visualizer
31 |
32 |
33 |
34 |
35 | {tabId === "text_settings" ? (
36 |
37 |
41 |
42 | ) : null}
43 | {tabId === "visualizer_settings" ? (
44 |
45 |
46 |
47 | ) : null}
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/Editor/Visualizer/AudioVisualizer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useRef, useState } from "react";
2 | import { Layer, Circle, Rect } from "react-konva";
3 | import { useAudioPlayer } from "react-use-audio-player";
4 | import { Howler } from "howler";
5 | import { useProjectStore } from "../../Project/store";
6 | import { getCurrentVisualizer } from "../utils";
7 | import { colorStopToArray } from "./store";
8 |
9 | interface MusicVisualizerProps {
10 | width: number;
11 | height: number;
12 | variant: "circle" | "vignette";
13 | position: number;
14 | }
15 |
16 | const MusicVisualizer: React.FC = ({
17 | width,
18 | height,
19 | variant,
20 | position,
21 | }) => {
22 | const lyricTexts = useProjectStore((state) => state.lyricTexts);
23 | const editingProject = useProjectStore((state) => state.editingProject);
24 |
25 | const [circleRadius, setCircleRadius] = useState(10);
26 | const [vignetteIntensity, setVignetteIntensity] = useState(0); // Adjusted to intensity for clarity
27 | const animationRef = useRef();
28 | const { playing } = useAudioPlayer();
29 | const analyserRef = useRef();
30 | const dataArrayRef = useRef();
31 |
32 | const initAnalyser = () => {
33 | if (!Howler.ctx || analyserRef.current) return;
34 |
35 | analyserRef.current = Howler.ctx.createAnalyser();
36 | Howler.masterGain.connect(analyserRef.current);
37 | analyserRef.current.fftSize = 64;
38 | const bufferLength = analyserRef.current.frequencyBinCount;
39 | dataArrayRef.current = new Uint8Array(bufferLength);
40 | };
41 |
42 | const animate = () => {
43 | if (!analyserRef.current || !dataArrayRef.current) {
44 | return;
45 | }
46 | analyserRef.current.getByteFrequencyData(dataArrayRef.current);
47 | const beatIntensity =
48 | dataArrayRef.current.slice(0, 10).reduce((acc, val) => acc + val, 0) / 10;
49 | const newRadius = Math.max(50, beatIntensity); // Map beat intensity to circle radius
50 | // Amplify the intensity for a more pronounced effect and ensure a higher base opacity
51 | const newIntensity = Math.min(1, beatIntensity / 256);
52 |
53 | setCircleRadius(newRadius);
54 | setVignetteIntensity(newIntensity);
55 | animationRef.current = requestAnimationFrame(animate);
56 | };
57 |
58 | const currentVisualizerSetting = useMemo(() => {
59 | return getCurrentVisualizer(lyricTexts, position);
60 | }, [lyricTexts, position]);
61 |
62 | useEffect(() => {
63 | if (playing) {
64 | initAnalyser();
65 | animationRef.current = requestAnimationFrame(animate);
66 | }
67 |
68 | return () => {
69 | cancelAnimationFrame(animationRef.current!);
70 | };
71 | }, [playing]); // Re-run if playing state changes
72 |
73 | // if (!playing) {
74 | // return null;
75 | // }
76 |
77 | if (currentVisualizerSetting?.visualizerSettings) {
78 | return (
79 |
80 | 0
99 | ? currentVisualizerSetting.visualizerSettings
100 | .fillRadialGradientEndRadius.beatSyncIntensity *
101 | vignetteIntensity
102 | : 1)
103 | )}
104 | fillRadialGradientColorStops={
105 | currentVisualizerSetting.visualizerSettings
106 | .fillRadialGradientColorStops
107 | ? colorStopToArray(
108 | currentVisualizerSetting.visualizerSettings
109 | .fillRadialGradientColorStops,
110 | vignetteIntensity
111 | )
112 | : []
113 | }
114 | />
115 |
116 | );
117 | } else {
118 | return <>>;
119 | }
120 |
121 | const PRESET = {
122 | eclipse: (
123 |
143 | ),
144 | magnetic: (
145 |
165 | ),
166 | abc: (
167 |
187 | ),
188 | };
189 |
190 | return (
191 |
192 | {variant === "circle" && (
193 |
194 | )}
195 |
196 | {variant === "vignette" && (
197 | //
217 | <>
218 | {editingProject?.name.includes("(Demo) Invent Animate - Dark")
219 | ? PRESET.eclipse
220 | : PRESET.magnetic}
221 | >
222 | )}
223 |
224 | );
225 | };
226 |
227 | // sun like
228 | /**
229 | *
247 | */
248 |
249 | export default MusicVisualizer;
250 |
--------------------------------------------------------------------------------
/src/Editor/Visualizer/store.ts:
--------------------------------------------------------------------------------
1 | import { RGBColor } from "react-color";
2 | import create from "zustand";
3 |
4 | export interface VisualizerSettingValue {
5 | value: number;
6 | beatSyncIntensity: number;
7 | }
8 |
9 | export interface ColorStop {
10 | stop: number;
11 | color: RGBColor;
12 | beatSyncIntensity: number;
13 | }
14 |
15 | export interface VisualizerSetting {
16 | fillRadialGradientStartPoint: { x: number; y: number };
17 | fillRadialGradientEndPoint: { x: number; y: number };
18 | fillRadialGradientStartRadius: VisualizerSettingValue;
19 | fillRadialGradientEndRadius: VisualizerSettingValue;
20 | fillRadialGradientColorStops: ColorStop[];
21 | }
22 |
23 | export const DEFAULT_VISUALIZER_SETTING: VisualizerSetting = {
24 | fillRadialGradientStartPoint: { x: 50, y: 50 },
25 | fillRadialGradientEndPoint: { x: 50, y: 50 },
26 | fillRadialGradientStartRadius: { value: 0, beatSyncIntensity: 0 },
27 | fillRadialGradientEndRadius: { value: 1, beatSyncIntensity: 1 },
28 | fillRadialGradientColorStops: [
29 | { stop: 0, color: { r: 255, g: 179, b: 186 }, beatSyncIntensity: 0 },
30 | { stop: 0.25, color: { r: 255, g: 223, b: 186 }, beatSyncIntensity: 0 },
31 | { stop: 0.76, color: { r: 255, g: 255, b: 186 }, beatSyncIntensity: 0 },
32 | { stop: 1, color: { r: 186, g: 255, b: 201 }, beatSyncIntensity: 0 },
33 | ],
34 | };
35 |
36 | export const useAudioVisualizerStore = create<{
37 | settings: VisualizerSetting[];
38 | updateSetting: (
39 | id: string,
40 | property: T,
41 | value: VisualizerSetting[T]
42 | ) => void;
43 | addSetting: (from: number, to: number, textBoxTimelineLevel: number) => void;
44 | }>((set) => ({
45 | settings: [],
46 | updateSetting: (
47 | id: string,
48 | property: T,
49 | value: VisualizerSetting[T]
50 | ) => {
51 | // set((state) => ({
52 | // settings: state.settings.map((setting) =>
53 | // setting.id === id ? { ...setting, [property]: value } : setting
54 | // ),
55 | // }));
56 | },
57 | addSetting: (from: number, to: number, textBoxTimelineLevel: number) => {
58 | set((state) => ({
59 | settings: [],
60 | }));
61 | },
62 | }));
63 |
64 | export function colorStopToArray(
65 | colorStops: ColorStop[],
66 | currentBeatIntensity?: number
67 | ): (number | string)[] {
68 | return colorStops.flatMap((colorStop) => {
69 | const { stop, color, beatSyncIntensity } = colorStop;
70 | let a = color.a ?? 1;
71 |
72 | if (beatSyncIntensity !== 0 && currentBeatIntensity) {
73 | a = beatSyncIntensity * currentBeatIntensity * a
74 | }
75 |
76 | const rgba = `rgba(${color.r}, ${color.g}, ${color.b}, ${a})`;
77 | return [stop, rgba];
78 | });
79 | }
80 |
--------------------------------------------------------------------------------
/src/Editor/store.ts:
--------------------------------------------------------------------------------
1 | import { GetState, SetState, create } from "zustand";
2 | import { LyricText, TimelineInteractionState } from "./types";
3 |
4 | interface DraggingLyricTextProgress {
5 | startLyricText: LyricText;
6 | endLyricText: LyricText;
7 | startY: number;
8 | endY: number;
9 | }
10 |
11 | export interface EditorStore {
12 | draggingLyricTextProgress?: DraggingLyricTextProgress;
13 | setDraggingLyricTextProgress: (progress?: DraggingLyricTextProgress) => void;
14 |
15 | timelineLayerY: number;
16 | setTimelineLayerY: (timelineLayerY: number) => void;
17 |
18 | timelineInteractionState: TimelineInteractionState;
19 | setTimelineInteractionState: (
20 | timelineInteractionState: TimelineInteractionState
21 | ) => void;
22 |
23 | editingText: LyricText | undefined;
24 | setEditingText: (lyricText: LyricText) => void;
25 | clearEditingText: () => void;
26 |
27 | selectedLyricTextIds: Set;
28 | setSelectedLyricTextIds: (ids: Set) => void;
29 |
30 | isCustomizationPanelOpen: boolean;
31 | toggleCustomizationPanelOpenState: (isOpen?: boolean) => void;
32 |
33 | customizationPanelTabId:
34 | | "reference"
35 | | "text_settings"
36 | | "visualizer_settings";
37 | setCustomizationPanelTabId: (
38 | id: "reference" | "text_settings" | "visualizer_settings"
39 | ) => void;
40 | }
41 |
42 | export const useEditorStore = create(
43 | (set: SetState, get: GetState): EditorStore => ({
44 | draggingLyricTextProgress: undefined,
45 | setDraggingLyricTextProgress: (progress?: DraggingLyricTextProgress) => {
46 | set({ draggingLyricTextProgress: progress });
47 | },
48 | timelineLayerY: 0,
49 | setTimelineLayerY: (timelineLayerY: number) => {
50 | set({ timelineLayerY });
51 | },
52 | timelineInteractionState: { width: 0, layerX: 0, cursorX: 0 },
53 | setTimelineInteractionState: (
54 | timelineInteractionState: TimelineInteractionState
55 | ) => {
56 | set({ timelineInteractionState });
57 | },
58 |
59 | editingText: undefined,
60 | setEditingText: (lyricText: LyricText) => {
61 | set({ editingText: lyricText });
62 | },
63 | clearEditingText: () => {
64 | set({ editingText: undefined });
65 | },
66 |
67 | selectedLyricTextIds: new Set([]),
68 | setSelectedLyricTextIds: (ids: Set) => {
69 | const { isCustomizationPanelOpen } = get();
70 | set({
71 | selectedLyricTextIds: ids,
72 | isCustomizationPanelOpen:
73 | ids.size === 0 ? false : isCustomizationPanelOpen,
74 | });
75 | },
76 |
77 | isCustomizationPanelOpen: false,
78 | toggleCustomizationPanelOpenState: (isOpen?: boolean) => {
79 | if (isOpen !== undefined) {
80 | set({ isCustomizationPanelOpen: isOpen });
81 | } else {
82 | const { isCustomizationPanelOpen } = get();
83 | set({ isCustomizationPanelOpen: !isCustomizationPanelOpen });
84 | }
85 | },
86 |
87 | customizationPanelTabId: "reference",
88 | setCustomizationPanelTabId: (
89 | id: "reference" | "text_settings" | "visualizer_settings"
90 | ) => {
91 | set({ customizationPanelTabId: id });
92 | },
93 | })
94 | );
95 |
--------------------------------------------------------------------------------
/src/Editor/types.ts:
--------------------------------------------------------------------------------
1 | import { RGBColor } from "react-color";
2 | import { TextCustomizationSettingType } from "./AudioTimeline/Tools/types";
3 | import { VisualizerSetting } from "./Visualizer/store";
4 |
5 | export const DEFAULT_TEXT_PREVIEW_WIDTH: number = 150;
6 | export const DEFAULT_TEXT_PREVIEW_HEIGHT: number = 100;
7 | export const DEFAULT_TEXT_PREVIEW_FONT_SIZE: number = 20;
8 | export const DEFAULT_TEXT_PREVIEW_FONT_COLOR: string = "white";
9 | export const DEFAULT_TEXT_PREVIEW_FONT_NAME: string = "Inter Variable";
10 | export const DEFAULT_TEXT_PREVIEW_FONT_WEIGHT: number = 400;
11 | export interface LyricText {
12 | id: number;
13 | start: number; // time this lyric begin
14 | end: number;
15 | text: string;
16 | textY: number;
17 | textX: number;
18 | textBoxTimelineLevel: number;
19 | width?: number;
20 | height?: number;
21 | [TextCustomizationSettingType.fontName]?: string;
22 | [TextCustomizationSettingType.fontSize]?: number;
23 | [TextCustomizationSettingType.fontColor]?: RGBColor;
24 | [TextCustomizationSettingType.fontWeight]?: number;
25 | [TextCustomizationSettingType.shadowBlur]?: number;
26 | [TextCustomizationSettingType.shadowColor]?: RGBColor;
27 | isImage?: boolean;
28 | isVisualizer?: boolean
29 | imageUrl?: string;
30 | visualizerSettings?: VisualizerSetting
31 | }
32 |
33 | export enum ScrollDirection {
34 | vertical = "vertical",
35 | horizontal = "horizontal",
36 | }
37 |
38 | export interface Coordinate {
39 | x: number;
40 | y: number;
41 | }
42 |
43 | export interface TimelineInteractionState {
44 | width: number;
45 | layerX: number;
46 | cursorX: number;
47 | // points: number[]
48 | }
49 |
--------------------------------------------------------------------------------
/src/Editor/utils.ts:
--------------------------------------------------------------------------------
1 | import { LyricText, ScrollDirection } from "./types";
2 |
3 | export const scaleY = (amplitude: number, height: number) => {
4 | const range = 256;
5 | const offset = 128;
6 |
7 | return height - ((amplitude + offset) * height) / range;
8 | };
9 |
10 | export function secondsToPixels(
11 | secondsToConvert: number,
12 | maxSeconds: number,
13 | maxPixels: number
14 | ): number {
15 | return (secondsToConvert / maxSeconds) * maxPixels;
16 | }
17 |
18 | export function pixelsToSeconds(
19 | pixelsToConvert: number,
20 | maxPixels: number,
21 | maxSeconds: number
22 | ): number {
23 | return (pixelsToConvert / maxPixels) * maxSeconds;
24 | }
25 |
26 | // export function getCurrentLyric(
27 | // lyricTexts: LyricText[],
28 | // position: number
29 | // ): LyricText | undefined {
30 | // let lyricText;
31 |
32 | // for (let index = 0; index < lyricTexts.length; index++) {
33 | // const element = lyricTexts[index];
34 | // if (position >= element.start && position <= element.end) {
35 | // lyricText = element;
36 | // break;
37 | // }
38 | // }
39 |
40 | // return lyricText;
41 | // }
42 |
43 | export function getCurrentVisualizer(
44 | lyricTexts: LyricText[],
45 | position: number
46 | ): LyricText | undefined {
47 | let lyricText;
48 |
49 | for (let index = 0; index < lyricTexts.length; index++) {
50 | const element = lyricTexts[index];
51 | if (
52 | position >= element.start &&
53 | position <= element.end &&
54 | element.isVisualizer &&
55 | element.visualizerSettings !== undefined
56 | ) {
57 | lyricText = element;
58 | break;
59 | }
60 | }
61 |
62 | return lyricText;
63 | }
64 |
65 | export function getCurrentLyrics(
66 | lyricTexts: LyricText[],
67 | position: number
68 | ): LyricText[] {
69 | let visibleLyricTexts: LyricText[] = [];
70 |
71 | for (let index = 0; index < lyricTexts.length; index++) {
72 | const element = lyricTexts[index];
73 |
74 | if (
75 | position >= element.start &&
76 | position <= element.end &&
77 | !element.isVisualizer
78 | ) {
79 | visibleLyricTexts.push(element);
80 | }
81 | }
82 |
83 | return visibleLyricTexts;
84 | }
85 |
86 | export function getCurrentLyricIndex(
87 | lyricTexts: LyricText[],
88 | position: number
89 | ): number | undefined {
90 | let indexFound;
91 |
92 | for (let index = 0; index < lyricTexts.length; index++) {
93 | const element = lyricTexts[index];
94 | if (
95 | position >= element.start &&
96 | position <= element.end &&
97 | !element.isVisualizer
98 | ) {
99 | indexFound = index;
100 | break;
101 | }
102 | }
103 |
104 | return indexFound;
105 | }
106 |
107 | export function getScrollDirection(
108 | prevX: number,
109 | curX: number,
110 | prevY: number,
111 | curY: number
112 | ): ScrollDirection {
113 | if (Math.abs(curX - prevX) > Math.abs(curY - prevY)) {
114 | return ScrollDirection.horizontal;
115 | }
116 |
117 | return ScrollDirection.vertical;
118 | }
119 |
120 | // higher level = further top away from timeline
121 | export function timelineLevelToY(level: number, timelineY: number) {
122 | return timelineY - 30 * level - 5;
123 | }
124 |
125 | // 35 = level height
126 | export function yToTimelineLevel(y: number, timelineY: number) {
127 | if (y >= timelineY - 30) {
128 | return 1;
129 | }
130 |
131 | return Math.round((Math.abs(y - timelineY) + 5) / 30);
132 | }
133 |
--------------------------------------------------------------------------------
/src/Homepage.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Grid, Header, View, Text, Button } from "@adobe/react-spectrum";
2 | import ProjectCard from "./Project/ProjectCard";
3 | import { useEffect, useRef, useState, useMemo } from "react";
4 | import { loadProjects, useProjectStore } from "./Project/store";
5 | import { useNavigate } from "react-router-dom";
6 | import { TypeAnimation } from "react-type-animation";
7 | import FeaturedProject from "./Project/Featured/FeaturedProject";
8 | import { checkFullScreen, useWindowSize } from "./utils";
9 | import RSC from "react-scrollbars-custom";
10 | import { useAudioPlayer } from "react-use-audio-player";
11 |
12 | export default function Homepage() {
13 | const { ready, pause } = useAudioPlayer();
14 | const isFullScreen = checkFullScreen();
15 | const { width: windowWidth, height: windowHeight } = useWindowSize();
16 |
17 | const contentRef = useRef(null);
18 | const [maxContentWidth, setMaxContentWidth] = useState(windowWidth);
19 | const [maxContentHeight, setMaxContentHeight] = useState(windowHeight);
20 | const { maxWidth, maxHeight: maxFeaturedHeight } = useMemo(() => {
21 | return calculate16by9Size(maxContentHeight ?? 0, maxContentWidth ?? 0);
22 | }, [maxContentHeight, maxContentWidth]);
23 |
24 | const existingProjects = useProjectStore((state) => state.existingProjects);
25 | const setExistingProjects = useProjectStore(
26 | (state) => state.setExistingProjects
27 | );
28 |
29 | const setEditingProject = useProjectStore((state) => state.setEditingProject);
30 | const setLyricTexts = useProjectStore((state) => state.updateLyricTexts);
31 | const setLyricReference = useProjectStore((state) => state.setLyricReference);
32 | const setIsCreateNewProjectPopupOpen = useProjectStore(
33 | (state) => state.setIsCreateNewProjectPopupOpen
34 | );
35 |
36 | const navigate = useNavigate();
37 |
38 | useEffect(() => {
39 | const fetchProjects = async () => {
40 | const projects = await loadProjects(true);
41 | setExistingProjects(projects);
42 | };
43 |
44 | fetchProjects();
45 | }, []);
46 |
47 | useEffect(() => {
48 | if (!contentRef.current) return;
49 | const resizeObserver = new ResizeObserver(() => {
50 | if (!isFullScreen) {
51 | const current = contentRef.current as any;
52 | setMaxContentWidth(current.offsetWidth);
53 | setMaxContentHeight(current.offsetHeight);
54 | }
55 | });
56 | resizeObserver.observe(contentRef.current);
57 | return () => resizeObserver.disconnect();
58 | }, [contentRef.current, isFullScreen]);
59 |
60 | function handleOnCreateClick() {
61 | if (ready) {
62 | pause();
63 | }
64 |
65 | setEditingProject(undefined);
66 | setLyricReference(undefined);
67 | setLyricTexts([]);
68 | setIsCreateNewProjectPopupOpen(true);
69 |
70 | navigate(`/edit`);
71 | }
72 |
73 | if (isFullScreen) {
74 | return (
75 |
76 |
77 |
78 |
79 |
80 | );
81 | }
82 |
83 | return (
84 |
85 |
96 |
97 |
98 |
115 |
116 |
117 |
118 |
122 |
123 |
128 |
132 |
133 |
134 |
141 |
149 |
157 |
164 |
175 | {existingProjects.map((p) => (
176 |
177 | ))}
178 | {/* {Array(10)
179 | .fill([...existingProjects])
180 | .flat()
181 | .map((p, index) => (
182 |
183 | ))} */}
184 |
185 |
186 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
202 |
203 |
204 |
205 | );
206 | }
207 |
208 | function calculate16by9Size(
209 | windowHeight: number,
210 | windowWidth: number,
211 | heightFactor: number = 0.4
212 | ) {
213 | const maxHeight = windowHeight * heightFactor;
214 | const maxWidth = (maxHeight * 16) / 9;
215 |
216 | if (maxWidth > windowWidth) {
217 | const adjustedHeight = (windowWidth * 9) / 16;
218 | return {
219 | maxWidth: windowWidth,
220 | maxHeight: adjustedHeight,
221 | };
222 | }
223 |
224 | return {
225 | maxWidth,
226 | maxHeight,
227 | };
228 | }
229 |
--------------------------------------------------------------------------------
/src/KonvaImage.tsx:
--------------------------------------------------------------------------------
1 | import useImage from "use-image";
2 | import { Image } from "react-konva";
3 |
4 | export const KonvaImage = ({
5 | url,
6 | width,
7 | height,
8 | }: {
9 | url: string;
10 | width: number;
11 | height: number;
12 | }) => {
13 | const [image] = useImage(url);
14 | return (
15 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/Project/CreateNewProjectButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ActionButton,
3 | AlertDialog,
4 | Button,
5 | ButtonGroup,
6 | Content,
7 | Dialog,
8 | DialogTrigger,
9 | Divider,
10 | Heading,
11 | } from "@adobe/react-spectrum";
12 | import { useState } from "react";
13 | import { useAIImageGeneratorStore } from "../Editor/Image/AI/store";
14 | import CreateNewProjectForm, { DataSource } from "./CreateNewProjectForm";
15 | import { isProjectExist, loadProjects, useProjectStore } from "./store";
16 | import { ProjectDetail } from "./types";
17 | import { useProjectService } from "./useProjectService";
18 |
19 | enum CreateProjectOutcome {
20 | missingStreamUrl = "Missing stream url",
21 | missingLocalAudio = "Missing local audio file",
22 | missingName = "Missing project name",
23 | duplicate = "Project with same name already exists",
24 | }
25 |
26 | export default function CreateNewProjectButton({
27 | hideButton = false,
28 | isEdit = false,
29 | }: {
30 | hideButton?: boolean;
31 | isEdit?: boolean;
32 | }) {
33 | const [saveProject] = useProjectService();
34 | const [creatingProject, setCreatingProject] = useState<
35 | ProjectDetail | undefined
36 | >();
37 | const [selectedDataSource, setSelectedDataSource] = useState(
38 | DataSource.local
39 | );
40 |
41 | const setExistingProjects = useProjectStore(
42 | (state) => state.setExistingProjects
43 | );
44 | const setEditingProject = useProjectStore((state) => state.setEditingProject);
45 | const setCreateNewProjectPopupOpen = useProjectStore(
46 | (state) => state.setIsCreateNewProjectPopupOpen
47 | );
48 | const setIsPopupOpen = useProjectStore((state) => state.setIsPopupOpen);
49 | const setLyricTexts = useProjectStore((state) => state.updateLyricTexts);
50 | const setUnSavedLyricReference = useProjectStore(
51 | (state) => state.setUnsavedLyricReference
52 | );
53 | const setLyricReference = useProjectStore((state) => state.setLyricReference);
54 |
55 | const setPromptLog = useAIImageGeneratorStore((state) => state.setPromptLog);
56 | const setGeneratedImageLog = useAIImageGeneratorStore(
57 | (state) => state.setGeneratedImageLog
58 | );
59 |
60 | const isCreateNewProjectPopupOpen = useProjectStore(
61 | (state) => state.isCreateNewProjectPopupOpen
62 | );
63 | const isEditProjectPopupOpen = useProjectStore(
64 | (state) => state.isEditProjectPopupOpen
65 | );
66 |
67 | const [attemptToCreateFailed, setAttemptToCreateFailed] =
68 | useState(false);
69 | const [createProjectOutcome, setCreateProjectOutcome] =
70 | useState();
71 |
72 | function onCreatePressed(close: () => void) {
73 | return () => {
74 | if (
75 | creatingProject &&
76 | creatingProject.name &&
77 | creatingProject.audioFileUrl &&
78 | !isProjectExist(creatingProject)
79 | ) {
80 | saveProject({
81 | id: creatingProject?.name,
82 | projectDetail: creatingProject,
83 | lyricTexts: [],
84 | lyricReference: "",
85 | generatedImageLog: [],
86 | promptLog: [],
87 | images: [],
88 | });
89 |
90 | const updateProjects = async () => {
91 | const projects = await loadProjects();
92 | setExistingProjects(projects);
93 | };
94 |
95 | updateProjects();
96 | setEditingProject(creatingProject);
97 | setLyricTexts([]);
98 | setUnSavedLyricReference("");
99 | setLyricReference("");
100 | setPromptLog([]);
101 | setGeneratedImageLog([]);
102 |
103 | close();
104 | setCreatingProject(undefined);
105 | } else {
106 | if (creatingProject && creatingProject.audioFileUrl.length === 0) {
107 | if (selectedDataSource === DataSource.local) {
108 | setCreateProjectOutcome(CreateProjectOutcome.missingLocalAudio);
109 | } else {
110 | setCreateProjectOutcome(CreateProjectOutcome.missingStreamUrl);
111 | }
112 | } else if (
113 | creatingProject &&
114 | creatingProject.name.length !== 0 &&
115 | isProjectExist(creatingProject)
116 | ) {
117 | setCreateProjectOutcome(CreateProjectOutcome.duplicate);
118 | } else if (creatingProject && !creatingProject.name) {
119 | setCreateProjectOutcome(CreateProjectOutcome.missingName);
120 | }
121 | setAttemptToCreateFailed(true);
122 | }
123 | };
124 | }
125 |
126 | return (
127 | {
129 | setIsPopupOpen(isOpen);
130 | setCreateNewProjectPopupOpen(isOpen);
131 |
132 | if (!isOpen) {
133 | setCreatingProject(undefined);
134 | setAttemptToCreateFailed(false);
135 | }
136 | }}
137 | isOpen={isCreateNewProjectPopupOpen || isEditProjectPopupOpen}
138 | >
139 | {!hideButton ? New : <>>}
140 | {(close) => (
141 |
178 | )}
179 |
180 | );
181 | }
182 |
--------------------------------------------------------------------------------
/src/Project/CreateNewProjectForm.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Flex,
3 | Form,
4 | Radio,
5 | RadioGroup,
6 | TextField,
7 | View,
8 | } from "@adobe/react-spectrum";
9 | import { useEffect } from "react";
10 | import { useDropzone } from "react-dropzone";
11 | import { EditingMode, ProjectDetail, VideoAspectRatio } from "./types";
12 | import ResolutionPicker from "./ResolutionPicker";
13 | import EditingModePicker from "./EditingModePicker";
14 |
15 | export enum DataSource {
16 | local = "local",
17 | stream = "stream",
18 | }
19 |
20 | export default function CreateNewProjectForm({
21 | creatingProject,
22 | setCreatingProject,
23 | selectedDataSource,
24 | setSelectedDataSource,
25 | }: {
26 | creatingProject?: ProjectDetail;
27 | setCreatingProject: (project: ProjectDetail) => void;
28 | selectedDataSource: DataSource;
29 | setSelectedDataSource: (dataSource: DataSource) => void;
30 | }) {
31 | const { acceptedFiles, getRootProps, getInputProps } = useDropzone();
32 |
33 | useEffect(() => {
34 | const file: any = acceptedFiles[0];
35 | if (file) {
36 | setCreatingProject({
37 | name: creatingProject?.name ? creatingProject?.name : file.path,
38 | createdDate: new Date(),
39 | audioFileName: file.path,
40 | audioFileUrl: URL.createObjectURL(file),
41 | isLocalUrl: true,
42 | resolution: creatingProject?.resolution ?? VideoAspectRatio["16/9"],
43 | editingMode: creatingProject?.editingMode ?? EditingMode.free,
44 | });
45 | }
46 | }, [acceptedFiles]);
47 |
48 | useEffect(() => {
49 | setCreatingProject({
50 | name: creatingProject?.name ? creatingProject.name : "",
51 | createdDate: new Date(),
52 | audioFileName: "",
53 | audioFileUrl: "",
54 | isLocalUrl: selectedDataSource === DataSource.local,
55 | resolution: creatingProject?.resolution ?? VideoAspectRatio["16/9"],
56 | editingMode: creatingProject?.editingMode ?? EditingMode.free,
57 | });
58 | }, [selectedDataSource]);
59 |
60 | const files = acceptedFiles.map((file: any) => {
61 | localStorage.setItem("test", JSON.stringify(file.path));
62 | console.log(file, URL.createObjectURL(file));
63 | return {file.path};
64 | });
65 |
66 | return (
67 |
193 | );
194 | }
195 |
--------------------------------------------------------------------------------
/src/Project/DeleteProjectButton.tsx:
--------------------------------------------------------------------------------
1 | import { AlertDialog, Button, DialogTrigger } from "@adobe/react-spectrum";
2 | import { useState } from "react";
3 | import { deleteProject } from "./store";
4 | import { Project } from "./types";
5 |
6 | export default function DeleteProjectButton({
7 | project,
8 | onProjectDelete,
9 | }: {
10 | project: Project;
11 | onProjectDelete: () => void;
12 | }) {
13 | const [showConfirmation, setShowConfirmation] = useState(false);
14 | return (
15 |
16 |
24 | {
30 | setShowConfirmation(false);
31 | }}
32 | onPrimaryAction={() => {
33 | deleteProject(project);
34 | setShowConfirmation(false);
35 | onProjectDelete()
36 | }}
37 | >
38 | Are you sure you want to delete {project.projectDetail.name}
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/Project/EditProjectButton.tsx:
--------------------------------------------------------------------------------
1 | import { ActionButton, Text } from "@adobe/react-spectrum";
2 | import Edit from "@spectrum-icons/workflow/Edit";
3 | import { useNavigate } from "react-router-dom";
4 |
5 | export default function EditProjectButton() {
6 | const navigate = useNavigate();
7 |
8 | return (
9 | {
11 | navigate(`/edit`);
12 | }}
13 | isQuiet
14 | >
15 |
16 | Edit
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/Project/EditingModePicker.tsx:
--------------------------------------------------------------------------------
1 | import { Picker, Item } from "@adobe/react-spectrum";
2 | import { EditingMode } from "./types";
3 |
4 | interface EditingModePickerProps {
5 | isRequired?: boolean;
6 | selectedMode?: EditingMode;
7 | onModeChange: (mode: EditingMode) => void;
8 | }
9 |
10 | export default function EditingModePicker({
11 | isRequired = true,
12 | selectedMode,
13 | onModeChange,
14 | }: EditingModePickerProps) {
15 | return (
16 | onModeChange(key as EditingMode)}
23 | >
24 | - {"Custom"}
25 | -
26 | {"Vertical Scrolled (Apple Music style)"}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/Project/Featured/FeaturedProject.tsx:
--------------------------------------------------------------------------------
1 | import LyricPreview from "../../Editor/Lyrics/LyricPreview/LyricPreview";
2 | import { View, Flex, Slider, ProgressCircle } from "@adobe/react-spectrum";
3 | import { useProjectStore } from "../store";
4 | import { Project, ProjectDetail } from "../types";
5 | import { useState, useEffect, useRef } from "react";
6 | import { useAudioPlayer, useAudioPosition } from "react-use-audio-player";
7 | import FullScreenButton from "../../Editor/AudioTimeline/Tools/FullScreenButton";
8 | import PlayPauseButton from "../../Editor/AudioTimeline/PlayBackControls";
9 | import formatDuration from "format-duration";
10 | import EditProjectButton from "../EditProjectButton";
11 |
12 | export default function FeaturedProject({
13 | maxWidth,
14 | maxHeight,
15 | }: {
16 | maxWidth: number;
17 | maxHeight: number;
18 | }) {
19 | const editingProject = useProjectStore((state) => state.editingProject);
20 | const setEditingProject = useProjectStore((state) => state.setEditingProject);
21 | const setLyricTexts = useProjectStore((state) => state.updateLyricTexts);
22 | const setLyricReference = useProjectStore((state) => state.setLyricReference);
23 | const setImageItems = useProjectStore((state) => state.setImages);
24 | const [projectLoading, setProjectLoading] = useState(true);
25 | const [streamingUrl, setStreamingUrl] = useState("");
26 |
27 | const {
28 | togglePlayPause,
29 | ready,
30 | loading,
31 | playing,
32 | pause,
33 | player,
34 | load,
35 | volume,
36 | } = useAudioPlayer({
37 | src: streamingUrl,
38 | format: ["mp3"],
39 | onloaderror: (id, error) => {
40 | console.log(" load error", error);
41 | },
42 | onload: () => {
43 | console.log("on load");
44 | },
45 | onend: () => console.log("sound has ended!"),
46 | });
47 |
48 | useEffect(() => {
49 | if (!editingProject) {
50 | const fetchData = async () => {
51 | try {
52 | const response = await fetch(
53 | "https://firebasestorage.googleapis.com/v0/b/angelic-phoenix-314404.appspot.com/o/featured.json?alt=media"
54 | );
55 | const project: Project = await response.json();
56 | setEditingProject(project.projectDetail as unknown as ProjectDetail);
57 | setLyricReference(project.lyricReference);
58 | setLyricTexts(project.lyricTexts);
59 | setImageItems(project.images ?? []);
60 | setStreamingUrl(project.projectDetail.audioFileUrl);
61 | } catch (error) {
62 | console.error("Error fetching data:", error);
63 | } finally {
64 | setProjectLoading(false);
65 | }
66 | };
67 |
68 | fetchData();
69 | } else {
70 | setProjectLoading(false);
71 | }
72 | }, [editingProject]);
73 |
74 | useEffect(() => {
75 | if (editingProject?.audioFileUrl) {
76 | setStreamingUrl(editingProject?.audioFileUrl);
77 | }
78 | }, [editingProject]);
79 |
80 | return (
81 |
93 | {!projectLoading && editingProject ? (
94 | <>
95 |
96 |
102 |
103 |
110 | >
111 | ) : (
112 |
121 |
122 |
123 | )}
124 |
125 | );
126 | }
127 |
128 | function PlaybackControlsOverlay({
129 | maxHeight,
130 | maxWidth,
131 | playing,
132 | togglePlayPause,
133 | projectDetail,
134 | }: {
135 | maxHeight: number;
136 | maxWidth: number;
137 | playing: boolean;
138 | togglePlayPause: () => void;
139 | projectDetail: ProjectDetail;
140 | }) {
141 | const { percentComplete, duration, seek, position } = useAudioPosition({
142 | highRefreshRate: false,
143 | });
144 | const [seekerPosition, setSeekerPosition] = useState(0);
145 | const [isOverlayHidden, setIsOverlayHidden] = useState(false);
146 | const timer = useRef();
147 | const DELAY = 2.5;
148 |
149 | useEffect(() => {
150 | setSeekerPosition((percentComplete / 100) * duration);
151 | }, [position, maxWidth]);
152 |
153 | useEffect(() => {
154 | return () => {
155 | clearInterval(timer.current);
156 | };
157 | }, []);
158 |
159 | return (
160 | {
169 | clearInterval(timer.current);
170 | setIsOverlayHidden(true);
171 | }}
172 | onMouseMove={() => {
173 | setIsOverlayHidden(false);
174 | clearInterval(timer.current);
175 | timer.current = setInterval(() => {
176 | setIsOverlayHidden(true);
177 | }, DELAY * 1000);
178 | }}
179 | >
180 |
191 |
199 |
200 |
201 |
209 |
210 |
211 |
220 | togglePlayPause()}
223 | />
224 |
225 |
236 | {projectDetail.name}
237 |
238 |
247 | {
256 | seek(value);
257 | }}
258 | />
259 |
260 |
270 | {formatDuration((percentComplete / 100) * duration * 1000)}
271 |
272 |
282 | -{formatDuration((1 - percentComplete / 100) * duration * 1000)}
283 |
284 |
285 |
286 | );
287 | }
288 |
--------------------------------------------------------------------------------
/src/Project/LoadProjectListButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ActionButton,
3 | AlertDialog,
4 | Button,
5 | ButtonGroup,
6 | Content,
7 | Dialog,
8 | DialogTrigger,
9 | Divider,
10 | Heading,
11 | View,
12 | } from "@adobe/react-spectrum";
13 | import { useEffect, useState } from "react";
14 | import { useDropzone } from "react-dropzone";
15 | import { useAIImageGeneratorStore } from "../Editor/Image/AI/store";
16 | import DeleteProjectButton from "./DeleteProjectButton";
17 | import ProjectList from "./ProjectList";
18 | import { loadProjects, useProjectStore } from "./store";
19 | import { Project, ProjectDetail } from "./types";
20 |
21 | export default function LoadProjectListButton({
22 | hideButton = false,
23 | }: {
24 | hideButton?: boolean;
25 | }) {
26 | const { acceptedFiles, getRootProps, getInputProps, open } = useDropzone();
27 |
28 | const setExistingProjects = useProjectStore(
29 | (state) => state.setExistingProjects
30 | );
31 | const setEditingProject = useProjectStore((state) => state.setEditingProject);
32 | const setIsPopupOpen = useProjectStore((state) => state.setIsPopupOpen);
33 | const isLoadProjectPopupOpen = useProjectStore(
34 | (state) => state.isLoadProjectPopupOpen
35 | );
36 | const setIsLoadProjectPopupOpen = useProjectStore(
37 | (state) => state.setIsLoadProjectPopupOpen
38 | );
39 | const setLyricTexts = useProjectStore((state) => state.updateLyricTexts);
40 | const setLyricReference = useProjectStore((state) => state.setLyricReference);
41 | const setUnsavedLyricReference = useProjectStore(
42 | (state) => state.setUnsavedLyricReference
43 | );
44 | const setImages = useProjectStore((state) => state.setImages);
45 | const setPromptLog = useAIImageGeneratorStore((state) => state.setPromptLog);
46 | const setGeneratedImageLog = useAIImageGeneratorStore(
47 | (state) => state.setGeneratedImageLog
48 | );
49 | const resetImageStore = useAIImageGeneratorStore((state) => state.reset);
50 |
51 | const [selectedProject, setSelectedProject] = useState();
52 | const [attemptToLoadFailed, setAttemptToLoadFailed] =
53 | useState(false);
54 |
55 | useEffect(() => {
56 | const fetchProjects = async () => {
57 | const projects = await loadProjects();
58 | setExistingProjects(projects);
59 | };
60 |
61 | if (isLoadProjectPopupOpen) {
62 | fetchProjects();
63 | }
64 | }, [isLoadProjectPopupOpen]);
65 |
66 | return (
67 | {
69 | setIsPopupOpen(isOpen);
70 | setIsLoadProjectPopupOpen(isOpen);
71 |
72 | if (!isOpen) {
73 | setSelectedProject(undefined);
74 | setAttemptToLoadFailed(false);
75 | acceptedFiles.pop();
76 | }
77 | }}
78 | isOpen={isLoadProjectPopupOpen}
79 | >
80 | {!hideButton ? (
81 | {
83 | const projects = await loadProjects();
84 | setExistingProjects(projects);
85 | }}
86 | >
87 | Load
88 |
89 | ) : (
90 | <>>
91 | )}
92 | {(close) => (
93 |
240 | )}
241 |
242 | );
243 | }
244 |
--------------------------------------------------------------------------------
/src/Project/Notice/FixedResolutionUpgrade.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import {
3 | Dialog,
4 | Button,
5 | Content,
6 | Heading,
7 | ActionButton,
8 | ButtonGroup,
9 | DialogTrigger,
10 | Divider,
11 | } from "@adobe/react-spectrum";
12 | import { useProjectStore } from "../store";
13 | import { VideoAspectRatio } from "../types";
14 | import { useProjectService } from "../useProjectService";
15 |
16 | interface FixedResolutionUpgradeNoticeProps {
17 | isOpen: boolean;
18 | onClose: () => void;
19 | }
20 |
21 | function FixedResolutionUpgradeNotice({
22 | isOpen,
23 | onClose,
24 | }: FixedResolutionUpgradeNoticeProps) {
25 | const [saveProject] = useProjectService();
26 | const setEditingProject = useProjectStore((state) => state.setEditingProject);
27 | const editingProject = useProjectStore((state) => state.editingProject);
28 |
29 | function handleConfirm() {
30 | if (editingProject) {
31 | setEditingProject({
32 | ...editingProject,
33 | resolution: VideoAspectRatio["16/9"],
34 | });
35 | saveProject(undefined, {
36 | ...editingProject,
37 | resolution: VideoAspectRatio["16/9"],
38 | });
39 | }
40 |
41 | onClose();
42 | }
43 |
44 | return (
45 | {
48 | if (!isOpen) {
49 | onClose();
50 | }
51 | }}
52 | >
53 | {/* Publish */}
54 | <>>
55 | {(close) => (
56 |
86 | )}
87 |
88 | );
89 | }
90 |
91 | export default FixedResolutionUpgradeNotice;
92 |
--------------------------------------------------------------------------------
/src/Project/Project.css:
--------------------------------------------------------------------------------
1 | @keyframes fadeIn {
2 | from {
3 | opacity: 0;
4 | }
5 | to {
6 | opacity: 1;
7 | }
8 | }
9 |
10 | .card {
11 | border: 1px solid #2d2d2d; /* Dark border for contrast */
12 | border-radius: 0.5rem; /* Rounded corners */
13 | overflow: hidden;
14 | width: fit-content;
15 | background-color: #1a1a1a; /* Dark background */
16 | transition: box-shadow 0.15s ease-in-out; /* Smooth transition for shadow */
17 | cursor: pointer;
18 | padding: 15px;
19 | animation: fadeIn 1.25s ease-in-out forwards; /* Apply the fadeIn animation */
20 | }
21 |
22 | .card:hover {
23 | box-shadow: rgba(255, 255, 255, 0.6) 0px 0px 50px -20px,
24 | rgba(255, 255, 255, 0.35) 0px 30px 60px -30px;
25 | }
26 |
--------------------------------------------------------------------------------
/src/Project/ProjectCard.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Heading, View, Text } from "@adobe/react-spectrum";
2 | import "./Project.css";
3 | import { Project, ProjectDetail } from "./types";
4 | import { useProjectStore } from "./store";
5 | import { useNavigate } from "react-router-dom";
6 |
7 | export default function ProjectCard({ project }: { project: Project }) {
8 | const setEditingProject = useProjectStore((state) => state.setEditingProject);
9 | const setLyricTexts = useProjectStore((state) => state.updateLyricTexts);
10 | const setLyricReference = useProjectStore((state) => state.setLyricReference);
11 | const setImageItems = useProjectStore((state) => state.setImages);
12 |
13 | const navigate = useNavigate();
14 |
15 | function handleOnClick() {
16 | setEditingProject(project.projectDetail as unknown as ProjectDetail);
17 | setLyricReference(project.lyricReference);
18 | setLyricTexts(project.lyricTexts);
19 | setImageItems(project.images ?? []);
20 |
21 | // navigate(`/edit`);
22 | }
23 |
24 | return (
25 |
26 |
36 |
42 |
43 | {project.projectDetail.albumArtSrc ? (
44 |
45 |
57 |
58 | ) : null}
59 |
60 |
61 | {project.projectDetail.name}
62 |
63 |
64 |
65 |
66 | by Lyrictor
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/Project/ProjectList.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Cell,
3 | Column,
4 | Row,
5 | TableBody,
6 | TableHeader,
7 | TableView,
8 | Text,
9 | } from "@adobe/react-spectrum";
10 | import { useProjectStore } from "./store";
11 | import { Project } from "./types";
12 |
13 | export default function ProjectList({
14 | onSelectionChange,
15 | }: {
16 | onSelectionChange: (project?: Project) => void;
17 | }) {
18 | const existingProjects = useProjectStore((state) => state.existingProjects);
19 |
20 | if (existingProjects.length === 0) {
21 | return No existing projects found;
22 | }
23 |
24 | return (
25 | {
32 | const project = existingProjects.find(
33 | (project) => project.id === key.currentKey
34 | );
35 | onSelectionChange(project);
36 | }}
37 | >
38 |
39 | Name
40 | Date Modified
41 |
42 |
43 | {existingProjects.map((item, i) => {
44 | return (
45 |
46 | {item?.projectDetail.name} |
47 | {item?.projectDetail.createdDate + ""} |
48 |
49 | );
50 | })}
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/Project/ResolutionPicker.tsx:
--------------------------------------------------------------------------------
1 | import { Picker, Item } from "@adobe/react-spectrum";
2 | import { VideoAspectRatio } from "./types";
3 |
4 | interface ResolutionPickerProps {
5 | isRequired?: boolean;
6 | selectedResolution?: VideoAspectRatio;
7 | onResolutionChange: (resolution: VideoAspectRatio) => void;
8 | }
9 |
10 | export default function ResolutionPicker({
11 | isRequired = true,
12 | selectedResolution,
13 | onResolutionChange,
14 | }: ResolutionPickerProps) {
15 | return (
16 | onResolutionChange(key as VideoAspectRatio)}
23 | >
24 | {- {"16/9"}
}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/Project/SaveButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Text } from "@adobe/react-spectrum";
2 | import { useProjectStore } from "./store";
3 | import { useProjectService } from "./useProjectService";
4 |
5 | export default function SaveButton() {
6 | const [saveProject] = useProjectService();
7 | const editingProject = useProjectStore((state) => state.editingProject);
8 |
9 | function isDemoProject() {
10 | return editingProject?.name.includes("(Demo)");
11 | }
12 |
13 | if (isDemoProject()) {
14 | return null
15 | }
16 |
17 | return (
18 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/Project/store.ts:
--------------------------------------------------------------------------------
1 | import create, { GetState, SetState } from "zustand";
2 | import { TextCustomizationSettingType } from "../Editor/AudioTimeline/Tools/types";
3 | import {
4 | DEFAULT_TEXT_PREVIEW_HEIGHT,
5 | DEFAULT_TEXT_PREVIEW_WIDTH,
6 | LyricText,
7 | } from "../Editor/types";
8 | import { Project, ProjectDetail } from "./types";
9 | import { VisualizerSetting } from "../Editor/Visualizer/store";
10 | import { ImageItem } from "../Editor/Image/Imported/ImportImageButton";
11 |
12 | const LYRIC_REFERENCE_VIEW_WIDTH = 380;
13 | const SETTINGS_SIDE_PANEL_VIEW_WIDTH = 350;
14 | const EXTRA_LYRIC_PREVIEW_WIDTH = -20;
15 | const LYRIC_PREVIEW_MAX_WIDTH =
16 | LYRIC_REFERENCE_VIEW_WIDTH +
17 | SETTINGS_SIDE_PANEL_VIEW_WIDTH -
18 | EXTRA_LYRIC_PREVIEW_WIDTH;
19 |
20 | export interface ProjectStore {
21 | editingProject?: ProjectDetail;
22 | setEditingProject: (project?: ProjectDetail) => void;
23 | isPopupOpen: boolean;
24 | setIsPopupOpen: (isOpen: boolean) => void;
25 | isCreateNewProjectPopupOpen: boolean;
26 | setIsCreateNewProjectPopupOpen: (isOpen: boolean) => void;
27 | isEditProjectPopupOpen: boolean;
28 | setIsEditProjectPopupOpen: (isOpen: boolean) => void;
29 | isLoadProjectPopupOpen: boolean;
30 | setIsLoadProjectPopupOpen: (isOpen: boolean) => void;
31 |
32 | lyricTexts: LyricText[];
33 | updateLyricTexts: (newLyricTexts: LyricText[]) => void;
34 | addNewLyricText: (
35 | text: string,
36 | start: number,
37 | isImage: boolean,
38 | imageUrl: string | undefined,
39 | isVisualizer: boolean,
40 | visualizerSettings: VisualizerSetting | undefined
41 | ) => void;
42 | isEditing: boolean;
43 | updateEditingStatus: () => void;
44 | modifyLyricTexts: (
45 | type: TextCustomizationSettingType,
46 | ids: number[],
47 | value: any
48 | ) => void;
49 | modifyVisualizerSettings: (
50 | type: keyof VisualizerSetting,
51 | ids: number[],
52 | value: any
53 | ) => void;
54 |
55 | lyricReference?: string;
56 | setLyricReference: (lyricReference?: string) => void;
57 | unSavedLyricReference?: string;
58 | setUnsavedLyricReference: (lyricReference?: string) => void;
59 |
60 | existingProjects: Project[];
61 | setExistingProjects: (projects: Project[]) => void;
62 |
63 | lyricTextsHistory: LyricText[][];
64 | undoLyricTextEdit: () => void;
65 |
66 | lyricTextsLastUndoHistory: LyricText[];
67 | redoLyricTextUndo: () => void;
68 |
69 | leftSidePanelMaxWidth: number;
70 | setLeftSidePanelMaxWidth: (width: number) => void;
71 |
72 | lyricsPreviewMaxWidth: number;
73 | setLyricsPreviewMaxWidth: (width: number) => void;
74 |
75 | rightSidePanelMaxWidth: number;
76 | setRightSidePanelMaxWidth: (width: number) => void;
77 |
78 | images: ImageItem[];
79 | setImages: (images: ImageItem[]) => void;
80 | addImages: (newImages: ImageItem[]) => void;
81 | removeImagesById: (idsToRemove: string[]) => void;
82 |
83 | isStaticSyncMode?: boolean;
84 | setToggleIsStaticSyncMode: () => void;
85 | }
86 |
87 | export const useProjectStore = create(
88 | (set: SetState, get: GetState): ProjectStore => ({
89 | editingProject: undefined,
90 | setEditingProject: (project?: ProjectDetail) => {
91 | set({ editingProject: project });
92 | },
93 | isPopupOpen: false,
94 | setIsPopupOpen: (isOpen: boolean) => {
95 | set({ isPopupOpen: isOpen });
96 | },
97 | isCreateNewProjectPopupOpen: false,
98 | setIsCreateNewProjectPopupOpen: (isOpen: boolean) => {
99 | set({ isCreateNewProjectPopupOpen: isOpen });
100 | },
101 | isEditProjectPopupOpen: false,
102 | setIsEditProjectPopupOpen: (isOpen: boolean) => {
103 | set({ isEditProjectPopupOpen: isOpen });
104 | },
105 | isLoadProjectPopupOpen: false,
106 | setIsLoadProjectPopupOpen: (isOpen: boolean) => {
107 | set({ isLoadProjectPopupOpen: isOpen });
108 | },
109 | lyricTexts: [],
110 | updateLyricTexts: (newLyricTexts: LyricText[]) => {
111 | const { lyricTexts, lyricTextsHistory } = get();
112 | lyricTextsHistory.push(lyricTexts);
113 |
114 | set({
115 | lyricTexts: newLyricTexts,
116 | lyricTextsHistory,
117 | });
118 | },
119 | addNewLyricText: (
120 | text: string,
121 | start: number,
122 | isImage: boolean,
123 | imageUrl: string | undefined,
124 | isVisualizer: boolean,
125 | visualizerSettings: VisualizerSetting | undefined
126 | ) => {
127 | const { lyricTexts, lyricTextsHistory } = get();
128 | const lyricTextToBeAdded: LyricText = {
129 | id: generateLyricTextId(),
130 | start,
131 | end: start + 1,
132 | text,
133 | textY: 0.5,
134 | textX: 0.5,
135 | textBoxTimelineLevel: getNewTextLevel(start, start + 1, lyricTexts),
136 | isImage,
137 | imageUrl,
138 | fontName: "Inter Variable",
139 | fontWeight: 400,
140 | isVisualizer,
141 | visualizerSettings,
142 | };
143 | let newLyricTexts = [...lyricTexts, lyricTextToBeAdded];
144 | newLyricTexts.sort((a, b) => a.start - b.start);
145 | let newLyricTextsHistory = [...lyricTextsHistory];
146 | newLyricTextsHistory.push(lyricTexts);
147 |
148 | set({
149 | lyricTexts: newLyricTexts,
150 | lyricTextsHistory: newLyricTextsHistory,
151 | });
152 | },
153 | isEditing: false,
154 | updateEditingStatus: () => {
155 | const { isEditing } = get();
156 |
157 | set({ isEditing: !isEditing });
158 | },
159 | modifyLyricTexts: (
160 | type: TextCustomizationSettingType,
161 | ids: number[],
162 | value: any
163 | ) => {
164 | const { lyricTexts } = get();
165 | const updateLyricTexts = lyricTexts.map(
166 | (curLoopLyricText: LyricText, updatedIndex: number) => {
167 | if (ids.includes(curLoopLyricText.id)) {
168 | return {
169 | ...curLoopLyricText,
170 | [type]: value,
171 | };
172 | }
173 |
174 | return curLoopLyricText;
175 | }
176 | );
177 |
178 | set({ lyricTexts: updateLyricTexts });
179 | },
180 | modifyVisualizerSettings(
181 | type: keyof VisualizerSetting,
182 | ids: number[],
183 | value: any
184 | ) {
185 | const { lyricTexts } = get();
186 | const updateLyricTexts = lyricTexts.map((curLoopLyricText: LyricText) => {
187 | if (
188 | ids.includes(curLoopLyricText.id) &&
189 | curLoopLyricText.visualizerSettings
190 | ) {
191 | return {
192 | ...curLoopLyricText,
193 | visualizerSettings: {
194 | ...curLoopLyricText.visualizerSettings,
195 | [type]: value,
196 | },
197 | };
198 | }
199 |
200 | return curLoopLyricText;
201 | });
202 |
203 | set({ lyricTexts: updateLyricTexts });
204 | },
205 | lyricReference: undefined,
206 | setLyricReference: (lyricReference?: string) => {
207 | set({ lyricReference });
208 | },
209 | unSavedLyricReference: undefined,
210 | setUnsavedLyricReference: (lyricReference?: string) => {
211 | set({ unSavedLyricReference: lyricReference });
212 | },
213 | existingProjects: [],
214 | setExistingProjects: (projects: Project[]) => {
215 | set({ existingProjects: projects });
216 | },
217 | lyricTextsHistory: [],
218 | undoLyricTextEdit: () => {
219 | const { lyricTextsHistory, lyricTexts } = get();
220 | const lastHistory = lyricTextsHistory.pop();
221 |
222 | if (lastHistory && lastHistory.length > 0) {
223 | set({
224 | lyricTexts: lastHistory,
225 | lyricTextsHistory,
226 | lyricTextsLastUndoHistory: lyricTexts,
227 | });
228 | }
229 | },
230 | lyricTextsLastUndoHistory: [],
231 | redoLyricTextUndo: () => {
232 | const { lyricTextsLastUndoHistory } = get();
233 |
234 | if (lyricTextsLastUndoHistory.length > 0) {
235 | set({
236 | lyricTexts: lyricTextsLastUndoHistory,
237 | lyricTextsLastUndoHistory: [],
238 | });
239 | }
240 | },
241 |
242 | leftSidePanelMaxWidth: LYRIC_REFERENCE_VIEW_WIDTH,
243 | setLeftSidePanelMaxWidth(width) {
244 | set({
245 | leftSidePanelMaxWidth: width,
246 | });
247 | },
248 | rightSidePanelMaxWidth: SETTINGS_SIDE_PANEL_VIEW_WIDTH,
249 | setRightSidePanelMaxWidth(width) {
250 | set({
251 | rightSidePanelMaxWidth: width,
252 | });
253 | },
254 | lyricsPreviewMaxWidth: LYRIC_PREVIEW_MAX_WIDTH,
255 | setLyricsPreviewMaxWidth(width) {},
256 |
257 | images: [],
258 | setImages(images) {
259 | set({
260 | images,
261 | });
262 | },
263 | addImages(newImages) {
264 | set((state) => ({
265 | images: [...state.images, ...newImages],
266 | }));
267 | },
268 | removeImagesById(idsToRemove) {
269 | set((state) => ({
270 | images: state.images.filter((image) => !idsToRemove.includes(image.id)),
271 | }));
272 | },
273 |
274 | isStaticSyncMode: false,
275 | setToggleIsStaticSyncMode() {
276 | set((state) => ({ isStaticSyncMode: !state.isStaticSyncMode }));
277 | },
278 | })
279 | );
280 |
281 | // level should be 1 level higher that the highest overlapping text box
282 | function getNewTextLevel(start: number, end: number, lyricTexts: LyricText[]) {
283 | const overlappingLyricTexts = lyricTexts.filter((lyricText) => {
284 | let isOverlapping: boolean = false;
285 | if (
286 | (lyricText.start <= start && lyricText.end >= end) ||
287 | (start >= lyricText.start && start <= lyricText.end) ||
288 | (end >= lyricText.start && end <= lyricText.end)
289 | ) {
290 | isOverlapping = true;
291 | }
292 | return isOverlapping;
293 | });
294 |
295 | if (overlappingLyricTexts.length === 0) {
296 | return 1;
297 | }
298 |
299 | return (
300 | overlappingLyricTexts.reduce((prev, cur) =>
301 | prev.textBoxTimelineLevel > cur.textBoxTimelineLevel ? prev : cur
302 | ).textBoxTimelineLevel + 1
303 | );
304 | }
305 |
306 | export const deleteProject = (project: Project) => {
307 | const existingLocalProjects = localStorage.getItem("lyrictorProjects");
308 |
309 | let existingProjects: Project[] | undefined = undefined;
310 |
311 | if (existingLocalProjects) {
312 | existingProjects = JSON.parse(existingLocalProjects) as Project[];
313 | }
314 |
315 | if (existingProjects) {
316 | localStorage.setItem(
317 | "lyrictorProjects",
318 | JSON.stringify(
319 | existingProjects.filter(
320 | (loopProject) =>
321 | loopProject.projectDetail.name !== project.projectDetail.name
322 | )
323 | )
324 | );
325 | }
326 | };
327 |
328 | export function isProjectExist(projectDetail: ProjectDetail) {
329 | const existingLocalProjects = localStorage.getItem("lyrictorProjects");
330 |
331 | let existingProjects: Project[] | undefined = undefined;
332 |
333 | if (existingLocalProjects) {
334 | existingProjects = JSON.parse(existingLocalProjects) as Project[];
335 | const duplicateProjectIndex = existingProjects.findIndex(
336 | (savedProject: Project) =>
337 | projectDetail.name === savedProject.projectDetail.name
338 | );
339 |
340 | return duplicateProjectIndex !== undefined && duplicateProjectIndex >= 0;
341 | }
342 |
343 | return false;
344 | }
345 |
346 | let cachedSampleProjects: Project[] = [];
347 |
348 | export const loadProjects = async (demoOnly?: boolean): Promise => {
349 | const existingLocalProjects = localStorage.getItem("lyrictorProjects");
350 | const sampleUrl =
351 | "https://firebasestorage.googleapis.com/v0/b/angelic-phoenix-314404.appspot.com/o/demo_projects.json?alt=media";
352 |
353 | const fetchSampleProjects = async (): Promise => {
354 | if (cachedSampleProjects.length > 0) {
355 | return cachedSampleProjects;
356 | }
357 | const response = await fetch(sampleUrl);
358 | if (!response.ok) {
359 | throw new Error(
360 | `Failed to fetch sample projects: ${response.statusText}`
361 | );
362 | }
363 | cachedSampleProjects = await response.json();
364 | return cachedSampleProjects;
365 | };
366 |
367 | if (demoOnly) {
368 | return await fetchSampleProjects();
369 | }
370 |
371 | if (existingLocalProjects) {
372 | const localProjects = JSON.parse(existingLocalProjects) as Project[];
373 | const sampleProjects = await fetchSampleProjects();
374 | return [...localProjects, ...sampleProjects];
375 | }
376 |
377 | return await fetchSampleProjects();
378 | };
379 |
380 | export const generateLyricTextId = () => {
381 | return new Date().getTime() + window.performance.now();
382 | };
383 |
--------------------------------------------------------------------------------
/src/Project/types.ts:
--------------------------------------------------------------------------------
1 | import { GeneratedImage, PromptParams } from "../Editor/Image/AI/types";
2 | import { ImageItem } from "../Editor/Image/Imported/ImportImageButton";
3 | import { LyricText } from "../Editor/types";
4 |
5 | export enum EditingMode {
6 | free = "free",
7 | static = "static",
8 | }
9 |
10 | export enum VideoAspectRatio {
11 | "16/9" = "16/9",
12 | "9/16" = "9/16",
13 | }
14 |
15 | export interface ProjectDetail {
16 | name: string;
17 | createdDate: Date;
18 | audioFileName: string;
19 | audioFileUrl: string;
20 | isLocalUrl: boolean;
21 | resolution?: VideoAspectRatio;
22 | albumArtSrc?: string;
23 | editingMode: EditingMode;
24 | }
25 |
26 | export interface Project {
27 | id: string;
28 | projectDetail: ProjectDetail;
29 | lyricTexts: LyricText[];
30 | lyricReference?: any;
31 | generatedImageLog: GeneratedImage[];
32 | promptLog: PromptParams[];
33 | images: ImageItem[];
34 | }
35 |
--------------------------------------------------------------------------------
/src/Project/useProjectService.ts:
--------------------------------------------------------------------------------
1 | import { useAIImageGeneratorStore } from "../Editor/Image/AI/store";
2 | import { useProjectStore } from "./store";
3 | import { Project, ProjectDetail } from "./types";
4 | import { ToastQueue } from "@react-spectrum/toast";
5 |
6 | export function useProjectService() {
7 | const editingProject = useProjectStore((state) => state.editingProject);
8 | const lyricTexts = useProjectStore((state) => state.lyricTexts);
9 | const unSavedLyricReference = useProjectStore(
10 | (state) => state.unSavedLyricReference
11 | );
12 | const lyricReference = useProjectStore((state) => state.lyricReference);
13 | const importedImages = useProjectStore((state) => state.images);
14 | const generatedImageLog = useAIImageGeneratorStore(
15 | (state) => state.generatedImageLog
16 | );
17 | const promptLog = useAIImageGeneratorStore((state) => state.promptLog);
18 |
19 | const saveProject = (
20 | suppliedProject?: Project,
21 | suppliedProjectDetails?: ProjectDetail
22 | ) => {
23 | let project: Project | undefined;
24 |
25 | if (suppliedProject) {
26 | project = suppliedProject;
27 | } else if (suppliedProjectDetails) {
28 | project = {
29 | id: suppliedProjectDetails.name,
30 | projectDetail: suppliedProjectDetails,
31 | lyricTexts,
32 | lyricReference: unSavedLyricReference ?? lyricReference,
33 | generatedImageLog,
34 | promptLog,
35 | images: importedImages
36 | };
37 | } else if (editingProject) {
38 | project = {
39 | id: editingProject.name,
40 | projectDetail: editingProject,
41 | lyricTexts,
42 | lyricReference: unSavedLyricReference ?? lyricReference,
43 | generatedImageLog,
44 | promptLog,
45 | images: importedImages
46 | };
47 | }
48 |
49 | // console.log(
50 | // "saving ",
51 | // project,
52 | // "unsavedlyricref:",
53 | // unSavedLyricReference,
54 | // "lyricref:",
55 | // lyricReference
56 | // );
57 |
58 | if (project) {
59 | const existingLocalProjects = localStorage.getItem("lyrictorProjects");
60 |
61 | let existingProjects: Project[] | undefined = undefined;
62 |
63 | if (existingLocalProjects) {
64 | existingProjects = JSON.parse(existingLocalProjects) as Project[];
65 | }
66 |
67 | if (existingProjects) {
68 | let newProjects = existingProjects;
69 | const duplicateProjectIndex = newProjects.findIndex(
70 | (savedProject: Project) =>
71 | project?.projectDetail.name === savedProject.projectDetail.name
72 | );
73 |
74 | if (duplicateProjectIndex !== undefined && duplicateProjectIndex >= 0) {
75 | newProjects[duplicateProjectIndex] = project;
76 | } else {
77 | newProjects.push(project);
78 | }
79 |
80 | localStorage.setItem("lyrictorProjects", JSON.stringify(newProjects));
81 |
82 | console.log("lyrictorProjects", newProjects);
83 |
84 | ToastQueue.positive(`Successfully saved to localStorage`, {
85 | timeout: 5000,
86 | });
87 | } else {
88 | localStorage.setItem("lyrictorProjects", JSON.stringify([project]));
89 | console.log("lyrictorProjects", project);
90 |
91 | ToastQueue.positive(`Successfully saved to localStorage`, {
92 | timeout: 5000,
93 | });
94 | }
95 | }
96 | };
97 |
98 | return [saveProject] as const;
99 | }
100 |
--------------------------------------------------------------------------------
/src/api/firebase.ts:
--------------------------------------------------------------------------------
1 | import { getApp, initializeApp } from "firebase/app";
2 | import { getAnalytics } from "firebase/analytics";
3 | import { getAuth, GoogleAuthProvider } from "firebase/auth";
4 | import firebaseConfig from "./firebaseConfig.json";
5 |
6 | const firebase = initializeApp(firebaseConfig);
7 | const analytics = getAnalytics(firebase);
8 |
9 | export default firebase;
10 | export const auth = getAuth(firebase);
11 | export const googleProvider = new GoogleAuthProvider();
12 |
--------------------------------------------------------------------------------
/src/api/firebaseConfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "apiKey": "AIzaSyCvuGthcZJ-ptcs8HUfqWtkIrJWKkiKTXY",
3 | "authDomain": "angelic-phoenix-314404.firebaseapp.com",
4 | "projectId": "angelic-phoenix-314404",
5 | "storageBucket": "angelic-phoenix-314404.appspot.com",
6 | "messagingSenderId": "350913443936",
7 | "appId": "1:350913443936:web:951e05fe54c424fed5eb05",
8 | "measurementId": "G-XLXE440CZN"
9 | }
10 |
--------------------------------------------------------------------------------
/src/declarations.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | webkitAudioContext: typeof AudioContext;
3 | }
4 |
--------------------------------------------------------------------------------
/src/github-mark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtCodes/lyrictor/21f404b38632d857946550d8ae311e1b5a5a24c2/src/github-mark.png
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Inter Variable",
5 | "Open Sans Variable", "Montserrat Variable", "Dancing Script Variable",
6 | "Caveat Variable", "Merienda Variable",
7 | "Big Shoulders Inline Display Variable", "Edu NSW ACT Foundation Variable",
8 | "Darker Grotesque Variable", "Red Hat Display Variable",
9 | "Roboto Mono Variable", "Comfortaa Variable", sans-serif;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 | }
13 |
14 | code {
15 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
16 | monospace;
17 | }
18 |
19 | .DraftEditor-root {
20 | font-size: 14px !important;
21 | }
22 |
23 | @tailwind base;
24 | @tailwind components;
25 | @tailwind utilities;
26 |
27 | .ScrollbarsCustom-Thumb {
28 | background: rgb(13, 13, 13) !important;
29 | }
30 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import "./index.css";
2 | import App from "./App";
3 |
4 | import '@fontsource-variable/inter';
5 | // Supports weights 300-800
6 | import '@fontsource-variable/open-sans';
7 | // Supports weights 100-900
8 | import '@fontsource-variable/montserrat';
9 | // Supports weights 400-700
10 | import '@fontsource-variable/dancing-script';
11 | // Supports weights 400-700
12 | import '@fontsource-variable/caveat';
13 | // Supports weights 300-900
14 | import '@fontsource-variable/merienda';
15 | // Supports weights 100-900
16 | import '@fontsource-variable/big-shoulders-inline-display';
17 | // Supports weights 400-700
18 | import '@fontsource-variable/edu-nsw-act-foundation';
19 | // Supports weights 300-900
20 | import '@fontsource-variable/darker-grotesque';
21 | // Supports weights 300-900
22 | import '@fontsource-variable/red-hat-display';
23 | // Supports weights 300-700
24 | import '@fontsource-variable/comfortaa';
25 | // Supports weights 100-700
26 | import '@fontsource-variable/roboto-mono';
27 |
28 | // Use createRoot to manage the root of your app
29 | import { createRoot } from "react-dom/client";
30 | const container = document.getElementById("root");
31 | const root = createRoot(container!);
32 | root.render();
33 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { useProjectStore } from "./Project/store";
2 | import { useEffect, useState } from "react";
3 |
4 | interface WindowSize {
5 | width?: number;
6 | height?: number;
7 | }
8 |
9 | export function useWindowSize() {
10 | // Initialize state with undefined width/height so server and client renders match
11 | // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
12 | const [windowSize, setWindowSize] = useState({});
13 | useEffect(() => {
14 | // Handler to call on window resize
15 | function handleResize() {
16 | // Set window width/height to state
17 | setWindowSize({
18 | width: window.innerWidth,
19 | height: window.innerHeight,
20 | });
21 | }
22 | // Add event listener
23 | window.addEventListener("resize", handleResize);
24 | // Call handler right away so state gets updated with initial window size
25 | handleResize();
26 | // Remove event listener on cleanup
27 | return () => window.removeEventListener("resize", handleResize);
28 | }, []); // Empty array ensures that effect is only run on mount
29 | return windowSize;
30 | }
31 |
32 | export function useKeyPress(targetKey: string) {
33 | const isPopupOpen = useProjectStore((state) => state.isPopupOpen);
34 | // State for keeping track of whether key is pressed
35 | const [keyPressed, setKeyPressed] = useState(false);
36 | // If pressed key is our target key then set to true
37 | function downHandler({
38 | key,
39 | target,
40 | }: {
41 | key: string;
42 | target: any;
43 | type: any;
44 | }) {
45 | const isInput =
46 | target.tagName === "INPUT" ||
47 | target.tagName === "TEXTAREA" ||
48 | target.contentEditable === "true";
49 |
50 | if (
51 | !isInput &&
52 | key === targetKey &&
53 | target.getAttribute("role") !== "textbox"
54 | ) {
55 | setKeyPressed(true);
56 | }
57 | }
58 | // If released key is our target key then set to false
59 | const upHandler = ({ key }: { key: string }) => {
60 | if (key === targetKey) {
61 | setKeyPressed(false);
62 | }
63 | };
64 | // Add event listeners
65 | useEffect(() => {
66 | if (isPopupOpen) {
67 | window.removeEventListener("keydown", downHandler);
68 | window.removeEventListener("keyup", upHandler);
69 | } else {
70 | window.addEventListener("keydown", downHandler);
71 | window.addEventListener("keyup", upHandler);
72 | }
73 | window.addEventListener("keydown", downHandler);
74 | window.addEventListener("keyup", upHandler);
75 | // Remove event listeners on cleanup
76 | return () => {
77 | window.removeEventListener("keydown", downHandler);
78 | window.removeEventListener("keyup", upHandler);
79 | };
80 | }, []); // Empty array ensures that effect is only run on mount and unmount
81 |
82 | return keyPressed;
83 | }
84 |
85 | export function useKeyPressCombination(
86 | targetKey: string,
87 | isShift: boolean = false
88 | ) {
89 | const isPopupOpen = useProjectStore((state) => state.isPopupOpen);
90 | const [keyPressed, setKeyPressed] = useState(false);
91 |
92 | function downHandler(e: any) {
93 | const isInput =
94 | e.target.tagName === "INPUT" ||
95 | e.target.tagName === "TEXTAREA" ||
96 | e.target.contentEditable === "true";
97 |
98 | if (!isInput && e.key === targetKey && (e.metaKey || e.ctrlKey)) {
99 | if ((isShift && e.shiftKey) || !isShift) {
100 | e.preventDefault(); // Prevent default only if not in input/textarea/contentEditable
101 | setKeyPressed(true);
102 | }
103 | } else {
104 | setKeyPressed(false);
105 | }
106 | }
107 |
108 | const upHandler = ({
109 | key,
110 | metaKey,
111 | ctrlKey,
112 | }: {
113 | key: string;
114 | metaKey: boolean;
115 | ctrlKey: boolean;
116 | }) => {
117 | if (key === targetKey || key === "Meta") {
118 | setKeyPressed(false);
119 | }
120 | };
121 |
122 | useEffect(() => {
123 | if (isPopupOpen) {
124 | window.removeEventListener("keydown", downHandler);
125 | window.removeEventListener("keyup", upHandler);
126 | } else {
127 | console.log("add combination listener");
128 | window.addEventListener("keydown", downHandler);
129 | window.addEventListener("keyup", upHandler);
130 | }
131 | return () => {
132 | window.removeEventListener("keydown", downHandler);
133 | window.removeEventListener("keyup", upHandler);
134 | };
135 | }, [isPopupOpen]);
136 |
137 | return keyPressed;
138 | }
139 |
140 | export function deepClone(object: any) {
141 | return JSON.parse(JSON.stringify(object));
142 | }
143 |
144 | export const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
145 |
146 | export function checkFullScreen() {
147 | if (isMobile) {
148 | return false;
149 | }
150 |
151 | const documentAny = document as any;
152 |
153 | return (
154 | (window.innerWidth == window.screen.width &&
155 | window.innerHeight == window.screen.height) ||
156 | documentAny.fullscreenElement ||
157 | documentAny.webkitIsFullScreen ||
158 | documentAny.mozFullScreen
159 | );
160 | }
161 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/**/*.{js,jsx,ts,tsx}",
5 | "node_modules/flowbite-react/lib/esm/**/*.js",
6 | ],
7 | theme: {
8 | extend: {
9 | boxShadow: {
10 | white: "white",
11 | "light-gray":
12 | "0 10px 15px -3px rgba(255, 255, 255, 0.1), 0 4px 6px -2px rgba(255, 255, 255, 0.05)",
13 | },
14 | },
15 | },
16 | plugins: [require("flowbite/plugin")],
17 | };
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------