Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e62cfc815 | ||
|
|
1501e4bf62 |
No files matched your search
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "r
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import logo from "../../assets/common/HITGENERATOR-Logo.png";
|
import logo from "../../assets/common/HITGENERATOR-Logo.png";
|
||||||
import { useAppDispatch, useAppSelector } from "../../store/hooks";
|
import { useAppDispatch, useAppSelector } from "../../store/hooks";
|
||||||
import { clearRequestError, request } from "../../store/slices/slice";
|
import { clearRequestError, request, resetState } from "../../store/slices/slice";
|
||||||
import styles from "./Picture.module.css";
|
import styles from "./Picture.module.css";
|
||||||
|
|
||||||
type AspectRatio = "1:1" | "4:5" | "16:9";
|
type AspectRatio = "1:1" | "4:5" | "16:9";
|
||||||
@@ -90,7 +90,11 @@ export default function Picture() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="На главную"
|
aria-label="На главную"
|
||||||
title="На главную"
|
title="На главную"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => {
|
||||||
|
navigate("/")
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">←</span>
|
<span aria-hidden="true">←</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.videoMark::before {
|
||||||
|
inset: 18px 15px 18px 22px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||||
|
}
|
||||||
@@ -1,6 +1,300 @@
|
|||||||
|
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import logo from "../../assets/common/HITGENERATOR-Logo.png";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../../store/hooks";
|
||||||
|
import { clearRequestError, request, resetState } from "../../store/slices/slice";
|
||||||
|
import pictureStyles from "../Picture/Picture.module.css";
|
||||||
|
import styles from "./VideoPage.module.css";
|
||||||
|
|
||||||
|
type AspectRatio = "1:1" | "4:5" | "16:9";
|
||||||
|
|
||||||
|
const aspectRatios: AspectRatio[] = ["1:1", "4:5", "16:9"];
|
||||||
|
|
||||||
export default function Video() {
|
export default function Video() {
|
||||||
return (
|
const navigate = useNavigate();
|
||||||
<div>video</div>
|
const dispatch = useAppDispatch();
|
||||||
)
|
const { loading, error: requestError } = useAppSelector((state) => state.request);
|
||||||
|
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [characters, setCharacters] = useState<File[]>([]);
|
||||||
|
const [scenes, setScenes] = useState<File[]>([]);
|
||||||
|
const [screenplays, setScreenplays] = useState<File[]>([]);
|
||||||
|
const [aspectRatio, setAspectRatio] = useState<AspectRatio>("16:9");
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(clearRequestError());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
function resetErrors() {
|
||||||
|
setFormError(null);
|
||||||
|
dispatch(clearRequestError());
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageFiles(
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
setFiles: React.Dispatch<React.SetStateAction<File[]>>,) {
|
||||||
|
const nextFiles = event.currentTarget.files?.[0] ?? null;
|
||||||
|
|
||||||
|
if (nextFiles && !nextFiles.type.startsWith("image/")) {
|
||||||
|
setFormError("Выберите файлы изображений.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetErrors();
|
||||||
|
setFiles((currentFiles) => nextFiles ? [...currentFiles, nextFiles] : currentFiles);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addScreenplayFiles(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const nextFiles = Array.from(event.currentTarget.files ?? []);
|
||||||
|
const isTextFile = (file: File) => {
|
||||||
|
return (file.type.startsWith("text/") || /\.(txt|md)$/i.test(file.name)) || file.type.startsWith("image/");
|
||||||
|
|
||||||
|
};
|
||||||
|
if (nextFiles.some((file) => !isTextFile(file))) {
|
||||||
|
setFormError("Выберите текстовые файлы в формате TXT или MD.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetErrors();
|
||||||
|
setScreenplays((currentFiles) => [...currentFiles, ...nextFiles]);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(
|
||||||
|
index: number,
|
||||||
|
setFiles: React.Dispatch<React.SetStateAction<File[]>>,
|
||||||
|
) {
|
||||||
|
setFiles((currentFiles) => currentFiles.filter((_, fileIndex) => fileIndex !== index));
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const text = prompt.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
setFormError("Добавьте описание видео.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
resetErrors();
|
||||||
|
setIsSubmitted(false);
|
||||||
|
|
||||||
|
await dispatch(
|
||||||
|
request({
|
||||||
|
id: Date.now(),
|
||||||
|
text,
|
||||||
|
files: [...characters, ...scenes, ...screenplays],
|
||||||
|
ratio: aspectRatio,
|
||||||
|
}),
|
||||||
|
).unwrap();
|
||||||
|
setIsSubmitted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewClassName = [
|
||||||
|
pictureStyles.previewCanvas,
|
||||||
|
aspectRatio === "1:1"
|
||||||
|
? pictureStyles.square
|
||||||
|
: aspectRatio === "4:5"
|
||||||
|
? pictureStyles.portrait
|
||||||
|
: pictureStyles.wide,
|
||||||
|
].join(" ");
|
||||||
|
const status = loading ? "Генерируем" : isSubmitted ? "Запрос принят" : "Ожидание";
|
||||||
|
//const attachmentsCount = characters.length + scenes.length + screenplays.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={pictureStyles.page}>
|
||||||
|
<header className={pictureStyles.toolbar}>
|
||||||
|
<div className={pictureStyles.brand}>
|
||||||
|
<button
|
||||||
|
className={pictureStyles.backButton}
|
||||||
|
type="button"
|
||||||
|
aria-label="На главную"
|
||||||
|
title="На главную"
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/");
|
||||||
|
resetState();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
</button>
|
||||||
|
<img className={pictureStyles.logo} src={logo} alt="ShitGenerator" />
|
||||||
|
</div>
|
||||||
|
<span className={pictureStyles.mode}>VIDEO</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={pictureStyles.workspace}>
|
||||||
|
<form className={pictureStyles.controls} onSubmit={handleSubmit}>
|
||||||
|
<div className={pictureStyles.heading}>
|
||||||
|
<p className={pictureStyles.eyebrow}>VIDEO LAB</p>
|
||||||
|
<h1>Генерация видео</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={pictureStyles.field}>
|
||||||
|
<span className={pictureStyles.labelRow}>
|
||||||
|
<span>Описание</span>
|
||||||
|
<span>{prompt.length}/800</span>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
maxLength={800}
|
||||||
|
placeholder="Опишите видео"
|
||||||
|
onChange={(event) => {
|
||||||
|
resetErrors();
|
||||||
|
setPrompt(event.target.value);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section className={pictureStyles.field} aria-labelledby="characters-label">
|
||||||
|
<span className={pictureStyles.labelRow} id="characters-label">
|
||||||
|
<span>Персонажи</span>
|
||||||
|
<span>Изображения</span>
|
||||||
|
</span>
|
||||||
|
{characters.map((file, index) => (
|
||||||
|
<div className={pictureStyles.fileSelected} key={`${file.name}-${index}`}>
|
||||||
|
<span className={pictureStyles.fileName}>{file.name}</span>
|
||||||
|
<button
|
||||||
|
className={pictureStyles.removeFileButton}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить файл ${file.name}`}
|
||||||
|
title="Удалить файл"
|
||||||
|
onClick={() => removeFile(index, setCharacters)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label className={pictureStyles.uploadButton}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(file) => addImageFiles(file, setCharacters)}
|
||||||
|
/>
|
||||||
|
<span>Добавить файлы</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={pictureStyles.field} aria-labelledby="scenes-label">
|
||||||
|
<span className={pictureStyles.labelRow} id="scenes-label">
|
||||||
|
<span>Сцены и локации</span>
|
||||||
|
<span>Изображения</span>
|
||||||
|
</span>
|
||||||
|
{scenes.map((file, index) => (
|
||||||
|
<div className={pictureStyles.fileSelected} key={`${file.name}-${index}`}>
|
||||||
|
<span className={pictureStyles.fileName}>{file.name}</span>
|
||||||
|
<button
|
||||||
|
className={pictureStyles.removeFileButton}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить файл ${file.name}`}
|
||||||
|
title="Удалить файл"
|
||||||
|
onClick={() => removeFile(index, setScenes)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label className={pictureStyles.uploadButton}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => addImageFiles(event, setScenes)}
|
||||||
|
/>
|
||||||
|
<span>Добавить файлы</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={pictureStyles.field} aria-labelledby="screenplays-label">
|
||||||
|
<span className={pictureStyles.labelRow} id="screenplays-label">
|
||||||
|
<span>Сценарий</span>
|
||||||
|
<span>TXT, MD</span>
|
||||||
|
</span>
|
||||||
|
{screenplays.map((file, index) => (
|
||||||
|
<div className={pictureStyles.fileSelected} key={`${file.name}-${index}`}>
|
||||||
|
<span className={pictureStyles.fileName}>{file.name}</span>
|
||||||
|
<button
|
||||||
|
className={pictureStyles.removeFileButton}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить файл ${file.name}`}
|
||||||
|
title="Удалить файл"
|
||||||
|
onClick={() => removeFile(index, setScreenplays)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label className={pictureStyles.uploadButton}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.md,text/plain,text/markdown,image/*"
|
||||||
|
multiple
|
||||||
|
onChange={addScreenplayFiles}
|
||||||
|
/>
|
||||||
|
<span>Добавить файлы</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<fieldset className={pictureStyles.field}>
|
||||||
|
<legend>Формат</legend>
|
||||||
|
<div className={pictureStyles.segmentedControl}>
|
||||||
|
{aspectRatios.map((ratio) => (
|
||||||
|
<button
|
||||||
|
className={ratio === aspectRatio ? pictureStyles.selected : undefined}
|
||||||
|
type="button"
|
||||||
|
key={ratio}
|
||||||
|
aria-pressed={ratio === aspectRatio}
|
||||||
|
onClick={() => {
|
||||||
|
setAspectRatio(ratio);
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ratio}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{(formError || requestError) && (
|
||||||
|
<p className={pictureStyles.error} role="alert">
|
||||||
|
{formError || requestError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className={pictureStyles.generateButton} type="submit" disabled={loading}>
|
||||||
|
{loading ? "Генерируем..." : "Сгенерировать"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section className={pictureStyles.previewPanel} aria-label="Область результата">
|
||||||
|
<div className={pictureStyles.previewHeader}>
|
||||||
|
<span>{status}</span>
|
||||||
|
<span className={pictureStyles.statusDot} data-active={loading || isSubmitted} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pictureStyles.previewArea}>
|
||||||
|
<div className={previewClassName}>
|
||||||
|
<div className={pictureStyles.emptyState}>
|
||||||
|
<span className={`${pictureStyles.emptyMark} ${styles.videoMark}`} aria-hidden="true" />
|
||||||
|
<span>{loading ? "Генерируем" : isSubmitted ? "Запрос принят" : "Ожидаемый ролик"}</span>
|
||||||
|
</div>
|
||||||
|
{loading && <span className={pictureStyles.scanLine} aria-hidden="true" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={pictureStyles.previewFooter}>
|
||||||
|
<span>{aspectRatio}</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,12 @@ export const requestSlice = createSlice({
|
|||||||
reducers: {
|
reducers: {
|
||||||
clearRequestError(state){
|
clearRequestError(state){
|
||||||
state.error = null;
|
state.error = null;
|
||||||
|
},
|
||||||
|
resetState(state){
|
||||||
|
state.files = [];
|
||||||
|
state.text = '';
|
||||||
|
state.loading = false;
|
||||||
|
state.error = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
@@ -57,6 +63,6 @@ export const requestSlice = createSlice({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const {clearRequestError} = requestSlice.actions;
|
export const {clearRequestError, resetState} = requestSlice.actions;
|
||||||
|
|
||||||
export default requestSlice.reducer;
|
export default requestSlice.reducer;
|
||||||
Reference in new issue
Block a user