This commit is contained in:
Qing
2022-09-15 22:21:27 +08:00
parent 3ac6ee7f44
commit 32854d40da
52 changed files with 2258 additions and 205 deletions

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useMemo } from 'react'
import { useKeyPressEvent } from 'react-use'
import { useRecoilState } from 'recoil'
import { nanoid } from 'nanoid'
import useInputImage from './hooks/useInputImage'
@@ -9,6 +8,7 @@ import Workspace from './components/Workspace'
import { fileState } from './store/Atoms'
import { keepGUIAlive } from './utils'
import Header from './components/Header/Header'
import useHotKey from './hooks/useHotkey'
// Keeping GUI Window Open
keepGUIAlive()
@@ -24,11 +24,15 @@ function App() {
}, [userInputImage, setFile])
// Dark Mode Hotkey
useKeyPressEvent('D', ev => {
ev?.preventDefault()
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
})
useHotKey(
'shift+d',
() => {
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
},
{},
[theme]
)
useEffect(() => {
document.body.setAttribute('data-theme', theme)

View File

@@ -1,4 +1,4 @@
import { Settings } from '../store/Atoms'
import { Rect, Settings } from '../store/Atoms'
import { dataURItoBlob } from '../utils'
export const API_ENDPOINT = `${process.env.REACT_APP_INPAINTING_URL}`
@@ -7,6 +7,8 @@ export default async function inpaint(
imageFile: File,
maskBase64: string,
settings: Settings,
croperRect: Rect,
prompt?: string,
sizeLimit?: string
) {
// 1080, 2000, Original
@@ -30,6 +32,18 @@ export default async function inpaint(
hdSettings.hdStrategyResizeLimit.toString()
)
fd.append('prompt', prompt === undefined ? '' : prompt)
fd.append('croperX', croperRect.x.toString())
fd.append('croperY', croperRect.y.toString())
fd.append('croperHeight', croperRect.height.toString())
fd.append('croperWidth', croperRect.width.toString())
fd.append('useCroper', settings.showCroper ? 'true' : 'false')
fd.append('sdStrength', settings.sdStrength.toString())
fd.append('sdSteps', settings.sdSteps.toString())
fd.append('sdGuidanceScale', settings.sdGuidanceScale.toString())
fd.append('sdSampler', settings.sdSampler.toString())
fd.append('sdSeed', settings.sdSeedFixed ? settings.sdSeed.toString() : '-1')
if (sizeLimit === undefined) {
fd.append('sizeLimit', '1080')
} else {

View File

@@ -0,0 +1,126 @@
@use 'sass:math';
$drag-handle-shortside: 12px;
$drag-handle-longside: 40px;
$half-handle-shortside: math.div($drag-handle-shortside, 2);
$half-handle-longside: math.div($drag-handle-longside, 2);
.crop-border {
outline-color: var(--yellow-accent);
outline-style: dashed;
}
.info-bar {
position: absolute;
pointer-events: auto;
font-size: 1rem;
padding: 0.2rem 0.8rem;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-color);
background-color: var(--page-bg);
border-radius: 9999px;
border: var(--editor-toolkit-panel-border);
box-shadow: 0 0 0 1px #0000001a, 0 3px 16px #00000014, 0 2px 6px 1px #00000017;
&:hover {
cursor: move;
}
}
.croper-wrapper {
position: absolute;
height: 100%;
width: 100%;
z-index: 2;
overflow: hidden;
pointer-events: none;
}
.croper {
position: relative;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 2;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
}
.drag-handle {
width: $drag-handle-shortside;
height: $drag-handle-shortside;
z-index: 4;
position: absolute;
display: block;
content: '';
border: 2px solid var(--yellow-accent);
background-color: var(--yellow-accent-light);
pointer-events: auto;
&:hover {
background-color: var(--yellow-accent);
}
}
.ord-topleft {
cursor: nw-resize;
top: (-$half-handle-shortside)-1px;
left: (-$half-handle-shortside)-1px;
}
.ord-topright {
cursor: ne-resize;
top: -($half-handle-shortside)-1px;
right: -($half-handle-shortside)-1px;
}
.ord-bottomright {
cursor: se-resize;
bottom: -($half-handle-shortside)-1px;
right: -($half-handle-shortside)-1px;
}
.ord-bottomleft {
cursor: sw-resize;
bottom: -($half-handle-shortside)-1px;
left: -($half-handle-shortside)-1px;
}
.ord-top,
.ord-bottom {
left: calc(50% - $half-handle-shortside);
cursor: ns-resize;
}
.ord-top {
top: (-$half-handle-shortside)-1px;
}
.ord-bottom {
bottom: -($half-handle-shortside)-1px;
}
.ord-left,
.ord-right {
top: calc(50% - $half-handle-shortside);
cursor: ew-resize;
}
.ord-left {
left: (-$half-handle-shortside)-1px;
}
.ord-right {
right: -($half-handle-shortside)-1px;
}

View File

@@ -0,0 +1,326 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/outline'
import React, { useEffect, useState } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import {
croperHeight,
croperWidth,
croperX,
croperY,
isInpaintingState,
} from '../../store/Atoms'
const DOC_MOVE_OPTS = { capture: true, passive: false }
const DRAG_HANDLE_BORDER = 2
const DRAG_HANDLE_SHORT = 12
const DRAG_HANDLE_LONG = 40
interface EVData {
initX: number
initY: number
initHeight: number
initWidth: number
startResizeX: number
startResizeY: number
ord: string // top/right/bottom/left
}
interface Props {
maxHeight: number
maxWidth: number
scale: number
minHeight: number
minWidth: number
}
const Croper = (props: Props) => {
const { minHeight, minWidth, maxHeight, maxWidth, scale } = props
const [x, setX] = useRecoilState(croperX)
const [y, setY] = useRecoilState(croperY)
const [height, setHeight] = useRecoilState(croperHeight)
const [width, setWidth] = useRecoilState(croperWidth)
const isInpainting = useRecoilValue(isInpaintingState)
const [isResizing, setIsResizing] = useState(false)
const [isMoving, setIsMoving] = useState(false)
useEffect(() => {
setX(Math.round((maxWidth - 512) / 2))
setY(Math.round((maxHeight - 512) / 2))
}, [maxHeight, maxWidth, minHeight, minWidth])
const [evData, setEVData] = useState<EVData>({
initX: 0,
initY: 0,
initHeight: 0,
initWidth: 0,
startResizeX: 0,
startResizeY: 0,
ord: 'top',
})
const onDragFocus = () => {
console.log('focus')
}
const checkTopBottomLimit = (newY: number, newHeight: number) => {
if (newY > 0 && newHeight > minHeight && newY + newHeight <= maxHeight) {
return true
}
return false
}
const checkLeftRightLimit = (newX: number, newWidth: number) => {
if (newX > 0 && newWidth > minWidth && newX + newWidth <= maxWidth) {
return true
}
return false
}
const onPointerMove = (e: PointerEvent) => {
if (isInpainting) {
return
}
const curX = e.clientX
const curY = e.clientY
if (isResizing) {
switch (evData.ord) {
case 'top': {
// TODO: 添加四个角以及 drag bar handle
const offset = Math.round((curY - evData.startResizeY) / scale)
const newHeight = evData.initHeight - offset
const newY = evData.initY + offset
if (checkTopBottomLimit(newY, newHeight)) {
setHeight(newHeight)
setY(newY)
}
break
}
case 'right': {
const offset = Math.round((curX - evData.startResizeX) / scale)
const newWidth = evData.initWidth + offset
if (checkLeftRightLimit(evData.initX, newWidth)) {
setWidth(newWidth)
}
break
}
case 'bottom': {
const offset = Math.round((curY - evData.startResizeY) / scale)
const newHeight = evData.initHeight + offset
if (checkTopBottomLimit(evData.initY, newHeight)) {
setHeight(newHeight)
}
break
}
case 'left': {
const offset = Math.round((curX - evData.startResizeX) / scale)
const newWidth = evData.initWidth - offset
const newX = evData.initX + offset
if (checkLeftRightLimit(newX, newWidth)) {
setWidth(newWidth)
setX(newX)
}
break
}
default:
break
}
}
if (isMoving) {
const offsetX = Math.round((curX - evData.startResizeX) / scale)
const offsetY = Math.round((curY - evData.startResizeY) / scale)
const newX = evData.initX + offsetX
const newY = evData.initY + offsetY
if (
checkLeftRightLimit(newX, evData.initWidth) &&
checkTopBottomLimit(newY, evData.initHeight)
) {
setX(newX)
setY(newY)
}
}
}
const onPointerDone = (e: PointerEvent) => {
if (isResizing) {
setIsResizing(false)
}
if (isMoving) {
setIsMoving(false)
}
}
useEffect(() => {
if (isResizing || isMoving) {
document.addEventListener('pointermove', onPointerMove, DOC_MOVE_OPTS)
document.addEventListener('pointerup', onPointerDone, DOC_MOVE_OPTS)
document.addEventListener('pointercancel', onPointerDone, DOC_MOVE_OPTS)
return () => {
document.removeEventListener(
'pointermove',
onPointerMove,
DOC_MOVE_OPTS
)
document.removeEventListener('pointerup', onPointerDone, DOC_MOVE_OPTS)
document.removeEventListener(
'pointercancel',
onPointerDone,
DOC_MOVE_OPTS
)
}
}
}, [isResizing, isMoving, width, height, evData])
const onCropPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const { ord } = (e.target as HTMLElement).dataset
if (ord) {
setIsResizing(true)
setEVData({
initX: x,
initY: y,
initHeight: height,
initWidth: width,
startResizeX: e.clientX,
startResizeY: e.clientY,
ord,
})
}
}
const createCropSelection = () => {
return (
<div
className="drag-elements"
onFocus={onDragFocus}
onPointerDown={onCropPointerDown}
>
<div
className="drag-handle ord-topleft"
data-ord="topleft"
aria-label="topleft"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
<div
className="drag-handle ord-topright"
data-ord="topright"
aria-label="topright"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
<div
className="drag-handle ord-bottomleft"
data-ord="bottomleft"
aria-label="bottomleft"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
<div
className="drag-handle ord-bottomright"
data-ord="bottomright"
aria-label="bottomright"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
<div
className="drag-handle ord-top"
data-ord="top"
aria-label="top"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
<div
className="drag-handle ord-right"
data-ord="right"
aria-label="right"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
<div
className="drag-handle ord-bottom"
data-ord="bottom"
aria-label="bottom"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
<div
className="drag-handle ord-left"
data-ord="left"
aria-label="left"
tabIndex={0}
role="button"
style={{ transform: `scale(${1 / scale})` }}
/>
</div>
)
}
const onInfoBarPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
setIsMoving(true)
setEVData({
initX: x,
initY: y,
initHeight: height,
initWidth: width,
startResizeX: e.clientX,
startResizeY: e.clientY,
ord: '',
})
}
const createInfoBar = () => {
return (
<div
className="info-bar"
onPointerDown={onInfoBarPointerDown}
style={{
transform: `scale(${1 / scale})`,
top: `${-28 / scale - 14}px`,
}}
>
<div className="crop-size">
{width} x {height}
</div>
</div>
)
}
const createBorder = () => {
return (
<div
className="crop-border"
style={{
height,
width,
outlineWidth: `${DRAG_HANDLE_BORDER / scale}px`,
}}
/>
)
}
return (
<div className="croper-wrapper">
<div className="croper" style={{ height, width, left: x, top: y }}>
{createBorder()}
{createInfoBar()}
{createCropSelection()}
</div>
</div>
)
}
export default Croper

View File

@@ -55,7 +55,7 @@
position: fixed;
bottom: 0.5rem;
border-radius: 3rem;
padding: 1rem 3rem;
padding: 0.6rem 3rem;
display: grid;
grid-template-areas: 'toolkit-size-selector toolkit-brush-slider toolkit-btns';
column-gap: 2rem;

View File

@@ -22,7 +22,6 @@ import Button from '../shared/Button'
import Slider from './Slider'
import SizeSelector from './SizeSelector'
import {
dataURItoBlob,
downloadImage,
isMidClick,
isRightClick,
@@ -30,7 +29,18 @@ import {
srcToFile,
useImage,
} from '../../utils'
import { settingState, toastState } from '../../store/Atoms'
import {
croperState,
isInpaintingState,
isSDState,
propmtState,
runManuallyState,
settingState,
toastState,
} from '../../store/Atoms'
import useHotKey from '../../hooks/useHotkey'
import Croper from '../Croper/Croper'
import emitter, { EVENT_PROMPT } from '../../event'
const TOOLBAR_SIZE = 200
const BRUSH_COLOR = '#ffcc00bb'
@@ -74,8 +84,14 @@ function mouseXY(ev: SyntheticEvent) {
export default function Editor(props: EditorProps) {
const { file } = props
const promptVal = useRecoilValue(propmtState)
const settings = useRecoilValue(settingState)
const croperRect = useRecoilValue(croperState)
const [toastVal, setToastState] = useRecoilState(toastState)
const [isInpainting, setIsInpainting] = useRecoilState(isInpaintingState)
const runMannually = useRecoilValue(runManuallyState)
const isSD = useRecoilValue(isSDState)
const [brushSize, setBrushSize] = useState(40)
const [original, isOriginalLoaded] = useImage(file)
const [renders, setRenders] = useState<HTMLImageElement[]>([])
@@ -90,7 +106,6 @@ export default function Editor(props: EditorProps) {
const [showRefBrush, setShowRefBrush] = useState(false)
const [isPanning, setIsPanning] = useState<boolean>(false)
const [showOriginal, setShowOriginal] = useState(false)
const [isInpaintingLoading, setIsInpaintingLoading] = useState(false)
const [scale, setScale] = useState<number>(1)
const [panned, setPanned] = useState<boolean>(false)
const [minScale, setMinScale] = useState<number>(1.0)
@@ -130,83 +145,28 @@ export default function Editor(props: EditorProps) {
[context, original]
)
const drawLinesOnMask = (_lineGroups: LineGroup[]) => {
if (!context?.canvas.width || !context?.canvas.height) {
throw new Error('canvas has invalid size')
}
maskCanvas.width = context?.canvas.width
maskCanvas.height = context?.canvas.height
const ctx = maskCanvas.getContext('2d')
if (!ctx) {
throw new Error('could not retrieve mask canvas')
}
_lineGroups.forEach(lineGroup => {
drawLines(ctx, lineGroup, 'white')
})
}
const runInpainting = async () => {
if (!hadDrawSomething()) {
return
}
const newLineGroups = [...lineGroups, curLineGroup]
setCurLineGroup([])
setIsDraging(false)
setIsInpaintingLoading(true)
if (settings.graduallyInpainting) {
drawLinesOnMask([curLineGroup])
} else {
drawLinesOnMask(newLineGroups)
}
let targetFile = file
if (settings.graduallyInpainting === true && renders.length > 0) {
console.info('gradually inpainting on last result')
const lastRender = renders[renders.length - 1]
targetFile = await srcToFile(lastRender.currentSrc, file.name, file.type)
}
try {
const res = await inpaint(
targetFile,
maskCanvas.toDataURL(),
settings,
sizeLimit.toString()
)
if (!res) {
throw new Error('empty response')
const drawLinesOnMask = useCallback(
(_lineGroups: LineGroup[]) => {
if (!context?.canvas.width || !context?.canvas.height) {
throw new Error('canvas has invalid size')
}
maskCanvas.width = context?.canvas.width
maskCanvas.height = context?.canvas.height
const ctx = maskCanvas.getContext('2d')
if (!ctx) {
throw new Error('could not retrieve mask canvas')
}
const newRender = new Image()
await loadImage(newRender, res)
const newRenders = [...renders, newRender]
setRenders(newRenders)
draw(newRender, [])
// Only append new LineGroup after inpainting success
setLineGroups(newLineGroups)
// clear redo stack
resetRedoState()
} catch (e: any) {
setToastState({
open: true,
desc: e.message ? e.message : e.toString(),
state: 'error',
duration: 2000,
_lineGroups.forEach(lineGroup => {
drawLines(ctx, lineGroup, 'white')
})
drawOnCurrentRender([])
}
setIsInpaintingLoading(false)
}
},
[context, maskCanvas]
)
const hadDrawSomething = () => {
const hadDrawSomething = useCallback(() => {
return curLineGroup.length !== 0
}
const hadRunInpainting = () => {
return renders.length !== 0
}
}, [curLineGroup])
const drawOnCurrentRender = useCallback(
(lineGroup: LineGroup) => {
@@ -219,8 +179,107 @@ export default function Editor(props: EditorProps) {
[original, renders, draw]
)
const runInpainting = useCallback(
async (prompt?: string) => {
console.log('runInpainting')
if (!hadDrawSomething()) {
return
}
console.log(prompt)
const newLineGroups = [...lineGroups, curLineGroup]
setCurLineGroup([])
setIsDraging(false)
setIsInpainting(true)
if (settings.graduallyInpainting) {
drawLinesOnMask([curLineGroup])
} else {
drawLinesOnMask(newLineGroups)
}
let targetFile = file
if (settings.graduallyInpainting === true && renders.length > 0) {
console.info('gradually inpainting on last result')
const lastRender = renders[renders.length - 1]
targetFile = await srcToFile(
lastRender.currentSrc,
file.name,
file.type
)
}
try {
const res = await inpaint(
targetFile,
maskCanvas.toDataURL(),
settings,
croperRect,
prompt,
sizeLimit.toString()
)
if (!res) {
throw new Error('empty response')
}
const newRender = new Image()
await loadImage(newRender, res)
const newRenders = [...renders, newRender]
setRenders(newRenders)
draw(newRender, [])
// Only append new LineGroup after inpainting success
setLineGroups(newLineGroups)
// clear redo stack
resetRedoState()
} catch (e: any) {
setToastState({
open: true,
desc: e.message ? e.message : e.toString(),
state: 'error',
duration: 4000,
})
drawOnCurrentRender([])
}
setIsInpainting(false)
},
[
lineGroups,
curLineGroup,
maskCanvas,
settings.graduallyInpainting,
settings,
croperRect,
sizeLimit,
promptVal,
drawOnCurrentRender,
hadDrawSomething,
drawLinesOnMask,
]
)
useEffect(() => {
emitter.on(EVENT_PROMPT, () => {
if (hadDrawSomething()) {
runInpainting(promptVal)
} else {
setToastState({
open: true,
desc: 'Please draw mask on picture',
state: 'error',
duration: 1500,
})
}
})
return () => {
emitter.off(EVENT_PROMPT)
}
}, [hadDrawSomething, runInpainting, prompt])
const hadRunInpainting = () => {
return renders.length !== 0
}
const handleMultiStrokeKeyDown = () => {
if (isInpaintingLoading) {
if (isInpainting) {
return
}
setIsMultiStrokeKeyPressed(true)
@@ -230,13 +289,13 @@ export default function Editor(props: EditorProps) {
if (!isMultiStrokeKeyPressed) {
return
}
if (isInpaintingLoading) {
if (isInpainting) {
return
}
setIsMultiStrokeKeyPressed(false)
if (!settings.runInpaintingManually) {
if (!runMannually) {
runInpainting()
}
}
@@ -246,7 +305,7 @@ export default function Editor(props: EditorProps) {
}
useKey(predicate, handleMultiStrokeKeyup, { event: 'keyup' }, [
isInpaintingLoading,
isInpainting,
isMultiStrokeKeyPressed,
hadDrawSomething,
])
@@ -257,7 +316,7 @@ export default function Editor(props: EditorProps) {
{
event: 'keydown',
},
[isInpaintingLoading]
[isInpainting]
)
// Draw once the original image is loaded
@@ -341,7 +400,7 @@ export default function Editor(props: EditorProps) {
}, [windowSize, resetZoom])
const handleEscPressed = () => {
if (isInpaintingLoading) {
if (isInpainting) {
return
}
if (isDraging || isMultiStrokeKeyPressed) {
@@ -361,7 +420,7 @@ export default function Editor(props: EditorProps) {
},
[
isDraging,
isInpaintingLoading,
isInpainting,
isMultiStrokeKeyPressed,
resetZoom,
drawOnCurrentRender,
@@ -404,7 +463,7 @@ export default function Editor(props: EditorProps) {
if (!canvas) {
return
}
if (isInpaintingLoading) {
if (isInpainting) {
return
}
if (!isDraging) {
@@ -416,13 +475,29 @@ export default function Editor(props: EditorProps) {
return
}
if (settings.runInpaintingManually) {
if (runMannually) {
setIsDraging(false)
} else {
runInpainting()
}
}
const isOutsideCroper = (clickPnt: { x: number; y: number }) => {
if (clickPnt.x < croperRect.x) {
return true
}
if (clickPnt.y < croperRect.y) {
return true
}
if (clickPnt.x > croperRect.x + croperRect.width) {
return true
}
if (clickPnt.y > croperRect.y + croperRect.height) {
return true
}
return false
}
const onMouseDown = (ev: SyntheticEvent) => {
if (isPanning) {
return
@@ -434,7 +509,7 @@ export default function Editor(props: EditorProps) {
if (!canvas) {
return
}
if (isInpaintingLoading) {
if (isInpainting) {
return
}
@@ -447,10 +522,14 @@ export default function Editor(props: EditorProps) {
return
}
if (isSD && settings.showCroper && isOutsideCroper(mouseXY(ev))) {
return
}
setIsDraging(true)
let lineGroup: LineGroup = []
if (isMultiStrokeKeyPressed || settings.runInpaintingManually) {
if (isMultiStrokeKeyPressed || runMannually) {
lineGroup = [...curLineGroup]
}
lineGroup.push({ size: brushSize, pts: [mouseXY(ev)] })
@@ -501,7 +580,7 @@ export default function Editor(props: EditorProps) {
}, [draw, renders, redoRenders, redoLineGroups, lineGroups, original])
const undo = () => {
if (settings.runInpaintingManually && curLineGroup.length !== 0) {
if (runMannually && curLineGroup.length !== 0) {
undoStroke()
} else {
undoRender()
@@ -527,14 +606,14 @@ export default function Editor(props: EditorProps) {
useKey(undoPredicate, undo, undefined, [undoStroke, undoRender])
const disableUndo = () => {
if (isInpaintingLoading) {
if (isInpainting) {
return true
}
if (renders.length > 0) {
return false
}
if (settings.runInpaintingManually) {
if (runMannually) {
if (curLineGroup.length === 0) {
return true
}
@@ -575,7 +654,7 @@ export default function Editor(props: EditorProps) {
}, [draw, renders, redoRenders, redoLineGroups, lineGroups, original])
const redo = () => {
if (settings.runInpaintingManually && redoCurLines.length !== 0) {
if (runMannually && redoCurLines.length !== 0) {
redoStroke()
} else {
redoRender()
@@ -603,14 +682,14 @@ export default function Editor(props: EditorProps) {
useKey(redoPredicate, redo, undefined, [redoStroke, redoRender])
const disableRedo = () => {
if (isInpaintingLoading) {
if (isInpainting) {
return true
}
if (redoRenders.length > 0) {
return false
}
if (settings.runInpaintingManually) {
if (runMannually) {
if (redoCurLines.length === 0) {
return true
}
@@ -688,7 +767,7 @@ export default function Editor(props: EditorProps) {
}, [showBrush, isPanning])
// Standard Hotkeys for Brush Size
useKeyPressEvent('[', () => {
useHotKey('[', () => {
setBrushSize(currentBrushSize => {
if (currentBrushSize > 10) {
return currentBrushSize - 10
@@ -700,18 +779,23 @@ export default function Editor(props: EditorProps) {
})
})
useKeyPressEvent(']', () => {
useHotKey(']', () => {
setBrushSize(currentBrushSize => {
return currentBrushSize + 10
})
})
// Manual Inpainting Hotkey
useKeyPressEvent('R', () => {
if (settings.runInpaintingManually && hadDrawSomething()) {
runInpainting()
}
})
useHotKey(
'shift+r',
() => {
if (runMannually && hadDrawSomething()) {
runInpainting()
}
},
{},
[runMannually]
)
// Toggle clean/zoom tool on spacebar.
useKeyPressEvent(
@@ -792,7 +876,7 @@ export default function Editor(props: EditorProps) {
}}
>
<TransformComponent
contentClass={isInpaintingLoading ? 'editor-canvas-loading' : ''}
contentClass={isInpainting ? 'editor-canvas-loading' : ''}
contentStyle={{
visibility: initialCentered ? 'visible' : 'hidden',
}}
@@ -852,10 +936,22 @@ export default function Editor(props: EditorProps) {
/>
</div>
</div>
{settings.showCroper ? (
<Croper
maxHeight={original.naturalHeight}
maxWidth={original.naturalWidth}
minHeight={Math.min(256, original.naturalHeight)}
minWidth={Math.min(256, original.naturalWidth)}
scale={scale}
/>
) : (
<></>
)}
</TransformComponent>
</TransformWrapper>
{showBrush && !isInpaintingLoading && !isPanning && (
{showBrush && !isInpainting && !isPanning && (
<div className="brush-shape" style={getBrushStyle(x, y)} />
)}
@@ -867,11 +963,15 @@ export default function Editor(props: EditorProps) {
)}
<div className="editor-toolkit-panel">
<SizeSelector
onChange={onSizeLimitChange}
originalWidth={original.naturalWidth}
originalHeight={original.naturalHeight}
/>
{isSD ? (
<></>
) : (
<SizeSelector
onChange={onSizeLimitChange}
originalWidth={original.naturalWidth}
originalHeight={original.naturalHeight}
/>
)}
<Slider
label="Brush"
min={10}
@@ -977,9 +1077,9 @@ export default function Editor(props: EditorProps) {
/>
</svg>
}
disabled={!hadDrawSomething() || isInpaintingLoading}
disabled={!hadDrawSomething() || isInpainting}
onClick={() => {
if (!isInpaintingLoading && hadDrawSomething()) {
if (!isInpainting && hadDrawSomething()) {
runInpainting()
}
}}

View File

@@ -1,6 +1,6 @@
header {
height: 60px;
padding: 1rem 2rem;
padding: 1rem 1.5rem;
position: absolute;
top: 0;
display: flex;
@@ -31,4 +31,4 @@ header {
align-items: center;
gap: 6px;
justify-self: end;
}
}

View File

@@ -1,17 +1,19 @@
import { ArrowLeftIcon, UploadIcon } from '@heroicons/react/outline'
import React, { useState } from 'react'
import { useRecoilState } from 'recoil'
import { fileState } from '../../store/Atoms'
import { useRecoilState, useRecoilValue } from 'recoil'
import { fileState, isSDState } from '../../store/Atoms'
import Button from '../shared/Button'
import Shortcuts from '../Shortcuts/Shortcuts'
import useResolution from '../../hooks/useResolution'
import { ThemeChanger } from './ThemeChanger'
import SettingIcon from '../Settings/SettingIcon'
import PromptInput from './PromptInput'
const Header = () => {
const [file, setFile] = useRecoilState(fileState)
const resolution = useResolution()
const [uploadElemId] = useState(`file-upload-${Math.random().toString()}`)
const isSD = useRecoilValue(isSDState)
const renderHeader = () => {
return (
@@ -37,6 +39,8 @@ const Header = () => {
</label>
</div>
{isSD && file ? <PromptInput /> : <></>}
<div className="header-icons-wrapper">
<ThemeChanger />
{file && (

View File

@@ -0,0 +1,18 @@
.prompt-wrapper {
display: flex;
gap: 12px;
}
.prompt-wrapper input {
all: unset;
border-width: 0;
border-radius: 0.5rem;
min-width: 600px;
padding: 0 0.8rem;
outline: 1px solid var(--border-color);
&:focus-visible {
border-width: 0;
outline: 1px solid var(--yellow-accent);
}
}

View File

@@ -0,0 +1,44 @@
import React, { FormEvent, useState } from 'react'
import { useRecoilState } from 'recoil'
import emitter, { EVENT_PROMPT } from '../../event'
import { appState, propmtState } from '../../store/Atoms'
import Button from '../shared/Button'
import TextInput from '../shared/Input'
// TODO: show progress in input
const PromptInput = () => {
const [app, setAppState] = useRecoilState(appState)
const [prompt, setPrompt] = useRecoilState(propmtState)
const handleOnInput = (evt: FormEvent<HTMLInputElement>) => {
evt.preventDefault()
evt.stopPropagation()
const target = evt.target as HTMLInputElement
setPrompt(target.value)
}
const handleRepaintClick = () => {
if (prompt.length !== 0 && !app.isInpainting) {
emitter.emit(EVENT_PROMPT)
}
}
return (
<div className="prompt-wrapper">
<TextInput
value={prompt}
onInput={handleOnInput}
placeholder="I want to repaint of..."
/>
<Button
border
onClick={handleRepaintClick}
disabled={prompt.length === 0 || app.isInpainting}
>
RePaint
</Button>
</div>
)
}
export default PromptInput

View File

@@ -1,6 +1,6 @@
import React, { ReactNode } from 'react'
import { useRecoilState } from 'recoil'
import { AIModel, settingState } from '../../store/Atoms'
import { AIModel, SDSampler, settingState } from '../../store/Atoms'
import Selector from '../shared/Selector'
import { Switch, SwitchThumb } from '../shared/Switch'
import Tooltip from '../shared/Tooltip'
@@ -145,6 +145,8 @@ function ModelSettingBlock() {
return undefined
case AIModel.FCF:
return renderFCFModelDesc()
case AIModel.SD14:
return undefined
default:
return <></>
}
@@ -182,6 +184,12 @@ function ModelSettingBlock() {
'https://arxiv.org/abs/2208.03382',
'https://github.com/SHI-Labs/FcF-Inpainting'
)
case AIModel.SD14:
return renderModelDesc(
'Stable Diffusion',
'https://ommer-lab.com/research/latent-diffusion-models/',
'https://github.com/CompVis/stable-diffusion'
)
default:
return <></>
}

View File

@@ -4,14 +4,28 @@ import SettingBlock from './SettingBlock'
interface NumberInputSettingProps {
title: string
allowFloat?: boolean
desc?: string
value: string
suffix?: string
width?: number
widthUnit?: string
disable?: boolean
onValue: (val: string) => void
}
function NumberInputSetting(props: NumberInputSettingProps) {
const { title, desc, value, suffix, onValue } = props
const {
title,
allowFloat,
desc,
value,
suffix,
onValue,
width,
widthUnit,
disable,
} = props
return (
<SettingBlock
@@ -28,8 +42,10 @@ function NumberInputSetting(props: NumberInputSettingProps) {
}}
>
<NumberInput
style={{ width: '80px' }}
allowFloat={allowFloat}
style={{ width: `${width}${widthUnit}` }}
value={`${value}`}
disabled={disable}
onValue={onValue}
/>
{suffix && <span>{suffix}</span>}
@@ -39,4 +55,11 @@ function NumberInputSetting(props: NumberInputSettingProps) {
)
}
NumberInputSetting.defaultProps = {
allowFloat: false,
width: 80,
widthUnit: 'px',
disable: false,
}
export default NumberInputSetting

View File

@@ -1,13 +1,14 @@
import React from 'react'
import { useRecoilState } from 'recoil'
import { settingState } from '../../store/Atoms'
import { useRecoilState, useRecoilValue } from 'recoil'
import { isSDState, settingState } from '../../store/Atoms'
import Modal from '../shared/Modal'
import ManualRunInpaintingSettingBlock from './ManualRunInpaintingSettingBlock'
import HDSettingBlock from './HDSettingBlock'
import ModelSettingBlock from './ModelSettingBlock'
import GraduallyInpaintingSettingBlock from './GraduallyInpaintingSettingBlock'
import DownloadMaskSettingBlock from './DownloadMaskSettingBlock'
import useHotKey from '../../hooks/useHotkey'
interface SettingModalProps {
onClose: () => void
@@ -15,6 +16,7 @@ interface SettingModalProps {
export default function SettingModal(props: SettingModalProps) {
const { onClose } = props
const [setting, setSettingState] = useRecoilState(settingState)
const isSD = useRecoilValue(isSDState)
const handleOnClose = () => {
setSettingState(old => {
@@ -23,6 +25,17 @@ export default function SettingModal(props: SettingModalProps) {
onClose()
}
useHotKey(
's',
() => {
setSettingState(old => {
return { ...old, show: !old.show }
})
},
{},
[]
)
return (
<Modal
onClose={handleOnClose}
@@ -30,11 +43,12 @@ export default function SettingModal(props: SettingModalProps) {
className="modal-setting"
show={setting.show}
>
<ManualRunInpaintingSettingBlock />
{isSD ? <></> : <ManualRunInpaintingSettingBlock />}
<GraduallyInpaintingSettingBlock />
<DownloadMaskSettingBlock />
<ModelSettingBlock />
<HDSettingBlock />
{isSD ? <></> : <HDSettingBlock />}
</Modal>
)
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { useKeyPressEvent } from 'react-use'
import { useRecoilState } from 'recoil'
import useHotKey from '../../hooks/useHotkey'
import { shortcutsState } from '../../store/Atoms'
import Button from '../shared/Button'
@@ -13,8 +13,7 @@ const Shortcuts = () => {
})
}
useKeyPressEvent('h', ev => {
ev?.preventDefault()
useHotKey('h', () => {
shortcutStateHandler()
})

View File

@@ -64,7 +64,8 @@ export default function ShortcutsModal() {
<ShortCut content="Decrease Brush Size" keys={['[']} />
<ShortCut content="Increase Brush Size" keys={[']']} />
<ShortCut content="Toggle Dark Mode" keys={['Shift', 'D']} />
<ShortCut content="Toggle Hotkeys Panel" keys={['H']} />
<ShortCut content="Toggle Hotkeys Dialog" keys={['H']} />
<ShortCut content="Toggle Settings Dialog" keys={['S']} />
</div>
</Modal>
)

View File

@@ -0,0 +1,57 @@
@use '../../styles/Mixins/' as *;
.side-panel {
position: absolute;
top: 68px;
right: 1.5rem;
padding: 0.3rem 0.3rem;
z-index: 4;
border-radius: 0.8rem;
border-style: solid;
border-color: var(--border-color);
border-width: 1px;
}
.side-panel-trigger {
font-family: 'WorkSans', sans-serif;
font-size: 16px;
border: 0px;
}
.side-panel-content {
position: relative;
font-family: 'WorkSans', sans-serif;
font-size: 14px;
top: 1rem;
right: 1.5rem;
padding: 1rem 1rem;
z-index: 9;
// backdrop-filter: blur(12px);
color: var(--text-color);
background-color: var(--page-bg);
border-radius: 0.8rem;
border-style: solid;
border-color: var(--border-color);
border-width: 1px;
display: flex;
flex-direction: column;
gap: 12px;
.setting-block-content {
gap: 1rem;
}
// input {
// height: 24px;
// // border-radius: 4px;
// }
// button {
// height: 28px;
// // border-radius: 4px;
// }
}

View File

@@ -0,0 +1,188 @@
import React, { useState } from 'react'
import { useRecoilState } from 'recoil'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { useToggle } from 'react-use'
import { SDSampler, settingState } from '../../store/Atoms'
import NumberInputSetting from '../Settings/NumberInputSetting'
import SettingBlock from '../Settings/SettingBlock'
import Selector from '../shared/Selector'
import { Switch, SwitchThumb } from '../shared/Switch'
import Button from '../shared/Button'
import emitter, { EVENT_PROMPT } from '../../event'
const INPUT_WIDTH = 30
// TODO: 添加收起来的按钮
const SidePanel = () => {
const [open, toggleOpen] = useToggle(false)
const [setting, setSettingState] = useRecoilState(settingState)
const onReRunBtnClick = () => {
emitter.emit(EVENT_PROMPT)
}
return (
<div className="side-panel">
<PopoverPrimitive.Root open={open}>
<PopoverPrimitive.Trigger
className="btn-primary side-panel-trigger"
onClick={() => toggleOpen()}
>
Stable Diffusion
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content className="side-panel-content">
<SettingBlock
title="Show Croper"
input={
<Switch
checked={setting.showCroper}
onCheckedChange={value => {
setSettingState(old => {
return { ...old, showCroper: value }
})
}}
>
<SwitchThumb />
</Switch>
}
/>
{/*
<NumberInputSetting
title="Num Samples"
width={INPUT_WIDTH}
value={`${setting.sdNumSamples}`}
desc=""
onValue={value => {
const val = value.length === 0 ? 0 : parseInt(value, 10)
setSettingState(old => {
return { ...old, sdNumSamples: val }
})
}}
/> */}
<NumberInputSetting
title="Steps"
width={INPUT_WIDTH}
value={`${setting.sdSteps}`}
desc="Large steps result in better result, but more time-consuming"
onValue={value => {
const val = value.length === 0 ? 0 : parseInt(value, 10)
setSettingState(old => {
return { ...old, sdSteps: val }
})
}}
/>
<NumberInputSetting
title="Strength"
width={INPUT_WIDTH}
allowFloat
value={`${setting.sdStrength}`}
desc="TODO"
onValue={value => {
const val = value.length === 0 ? 0 : parseFloat(value)
console.log(val)
setSettingState(old => {
return { ...old, sdStrength: val }
})
}}
/>
<NumberInputSetting
title="Guidance Scale"
width={INPUT_WIDTH}
allowFloat
value={`${setting.sdGuidanceScale}`}
desc="TODO"
onValue={value => {
const val = value.length === 0 ? 0 : parseFloat(value)
setSettingState(old => {
return { ...old, sdGuidanceScale: val }
})
}}
/>
<SettingBlock
className="sub-setting-block"
title="Sampler"
input={
<Selector
width={80}
value={setting.sdSampler as string}
options={Object.values(SDSampler)}
onChange={val => {
const sampler = val as SDSampler
setSettingState(old => {
return { ...old, sdSampler: sampler }
})
}}
/>
}
/>
<SettingBlock
title="Seed"
input={
<div
style={{
display: 'flex',
gap: 0,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Button
onClick={onReRunBtnClick}
icon={
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="20px"
height="20px"
viewBox="0 0 66.459 66.46"
>
<path
d="M65.542,11.777L33.467,0.037c-0.133-0.049-0.283-0.049-0.42,0L0.916,11.748c-0.242,0.088-0.402,0.32-0.402,0.576 l0.09,40.484c0,0.25,0.152,0.475,0.385,0.566l31.047,12.399v0.072c0,0.203,0.102,0.393,0.27,0.508 c0.168,0.111,0.379,0.135,0.57,0.062l0.385-0.154l0.385,0.154c0.072,0.028,0.15,0.045,0.227,0.045c0.121,0,0.24-0.037,0.344-0.105 c0.168-0.115,0.27-0.305,0.27-0.508v-0.072l31.047-12.399c0.232-0.093,0.385-0.316,0.385-0.568l0.027-40.453 C65.943,12.095,65.784,11.867,65.542,11.777z M32.035,63.134L3.052,51.562V15.013l28.982,11.572L32.035,63.134L32.035,63.134z M33.259,24.439L4.783,13.066l28.48-10.498l28.735,10.394L33.259,24.439z M63.465,51.562L34.484,63.134V26.585l28.981-11.572 V51.562z M14.478,38.021c0-1.692,1.35-2.528,3.016-1.867c1.665,0.663,3.016,2.573,3.016,4.269 c-0.001,1.692-1.351,2.529-3.017,1.867C15.827,41.626,14.477,39.714,14.478,38.021z M5.998,25.375c0-1.693,1.351-2.529,3.017-1.866 c1.666,0.662,3.016,2.572,3.016,4.267c0,1.695-1.351,2.529-3.017,1.867C7.347,28.979,5.998,27.069,5.998,25.375z M22.959,32.124 c0-1.694,1.351-2.53,3.017-1.867c1.666,0.663,3.016,2.573,3.016,4.267c0,1.695-1.352,2.53-3.017,1.867 C24.309,35.728,22.959,33.818,22.959,32.124z M5.995,43.103c0.001-1.692,1.351-2.529,3.017-1.867 c1.666,0.664,3.016,2.573,3.016,4.269c0,1.694-1.351,2.53-3.017,1.867C7.344,46.709,5.995,44.797,5.995,43.103z M22.957,49.853 c0.001-1.695,1.351-2.529,3.017-1.867s3.016,2.572,3.016,4.269c0,1.692-1.351,2.528-3.017,1.866 C24.306,53.458,22.957,51.546,22.957,49.853z M27.81,12.711c-0.766,1.228-3.209,2.087-5.462,1.917 c-2.253-0.169-3.46-1.301-2.695-2.528c0.765-1.227,3.207-2.085,5.461-1.916C27.365,10.352,28.573,11.484,27.81,12.711z M43.928,13.921c-0.764,1.229-3.208,2.086-5.46,1.917c-2.255-0.169-3.46-1.302-2.696-2.528c0.764-1.229,3.209-2.086,5.462-1.918 C43.485,11.563,44.693,12.695,43.928,13.921z M47.04,42.328c-1.041-1.278-0.764-3.705,0.619-5.421 c1.381-1.716,3.344-2.069,4.381-0.792c1.041,1.276,0.764,3.704-0.617,5.42S48.079,43.604,47.04,42.328z"
fill="currentColor"
/>
</svg>
}
/>
{/* 每次会从服务器返回更新该值 */}
<NumberInputSetting
title=""
width={80}
value={`${setting.sdSeed}`}
desc=""
disable={!setting.sdSeedFixed}
onValue={value => {
const val = value.length === 0 ? 0 : parseInt(value, 10)
setSettingState(old => {
return { ...old, sdSeed: val }
})
}}
/>
<Switch
checked={setting.sdSeedFixed}
onCheckedChange={value => {
setSettingState(old => {
return { ...old, sdSeedFixed: value }
})
}}
style={{ marginLeft: '8px' }}
>
<SwitchThumb />
</Switch>
</div>
}
/>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
</div>
)
}
export default SidePanel

View File

@@ -10,6 +10,7 @@ import {
modelDownloaded,
switchModel,
} from '../adapters/inpainting'
import SidePanel from './SidePanel/SidePanel'
interface WorkspaceProps {
file: File
@@ -82,6 +83,7 @@ const Workspace = ({ file }: WorkspaceProps) => {
return (
<>
<SidePanel />
<Editor file={file} />
<SettingModal onClose={onSettingClose} />
<ShortcutsModal />

View File

@@ -4,6 +4,7 @@
display: grid;
grid-auto-flow: column;
column-gap: 1rem;
background-color: var(--page-bg);
color: var(--btn-text-color);
font-family: 'WorkSans', sans-serif;
width: max-content;
@@ -25,6 +26,13 @@
}
.btn-primary-disabled {
background-color: var(--page-bg);
pointer-events: none;
opacity: 0.5;
}
.btn-border {
border-color: var(--btn-border-color);
border-width: 1px;
border-style: solid;
}

View File

@@ -1,6 +1,7 @@
import React, { ReactNode } from 'react'
interface ButtonProps {
border?: boolean
disabled?: boolean
children?: ReactNode
className?: string
@@ -17,6 +18,7 @@ interface ButtonProps {
const Button: React.FC<ButtonProps> = props => {
const {
children,
border,
className,
disabled,
icon,
@@ -55,6 +57,7 @@ const Button: React.FC<ButtonProps> = props => {
toolTip ? 'info-tooltip' : '',
tooltipPosition ? `info-tooltip-${tooltipPosition}` : '',
className,
border ? `btn-border` : '',
].join(' ')}
>
{icon}
@@ -65,6 +68,7 @@ const Button: React.FC<ButtonProps> = props => {
Button.defaultProps = {
disabled: false,
border: false,
}
export default Button

View File

@@ -0,0 +1,42 @@
import React, { FocusEvent, InputHTMLAttributes } from 'react'
import { useRecoilState } from 'recoil'
import { appState } from '../../store/Atoms'
const TextInput = React.forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>((props: InputHTMLAttributes<HTMLInputElement>, forwardedRef) => {
const { onFocus, onBlur, ...itemProps } = props
const [_, setAppState] = useRecoilState(appState)
const handleOnFocus = (evt: FocusEvent<any>) => {
setAppState(old => {
return { ...old, disableShortCuts: true }
})
onFocus?.(evt)
}
const handleOnBlur = (evt: FocusEvent<any>) => {
setAppState(old => {
return { ...old, disableShortCuts: false }
})
onBlur?.(evt)
}
return (
<input
{...itemProps}
ref={forwardedRef}
type="text"
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onKeyDown={e => {
if (e.key === 'Escape') {
e.currentTarget.blur()
}
}}
/>
)
})
export default TextInput

View File

@@ -1,7 +1,9 @@
import { XIcon } from '@heroicons/react/outline'
import React, { ReactNode } from 'react'
import { useRecoilState } from 'recoil'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import Button from './Button'
import { appState } from '../../store/Atoms'
export interface ModalProps {
show: boolean
@@ -16,10 +18,14 @@ const Modal = React.forwardRef<
ModalProps
>((props, forwardedRef) => {
const { show, children, onClose, className, title } = props
const [_, setAppState] = useRecoilState(appState)
const onOpenChange = (open: boolean) => {
if (!open) {
onClose?.()
setAppState(old => {
return { ...old, disableShortCuts: false }
})
}
}

View File

@@ -5,8 +5,13 @@
padding: 0 0.8rem;
outline: 1px solid var(--border-color);
height: 32px;
text-align: right;
&:focus-visible {
outline: 1px solid var(--yellow-accent);
}
&:disabled {
color: var(--border-color);
}
}

View File

@@ -1,31 +1,44 @@
import React, { FormEvent, InputHTMLAttributes } from 'react'
import React, { FormEvent, InputHTMLAttributes, useState } from 'react'
import TextInput from './Input'
interface NumberInputProps extends InputHTMLAttributes<HTMLInputElement> {
value: string
allowFloat?: boolean
onValue?: (val: string) => void
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(props: NumberInputProps, forwardedRef) => {
const { value, onValue, ...itemProps } = props
const { value, allowFloat, onValue, ...itemProps } = props
const [innerValue, setInnerValue] = useState(value)
const handleOnInput = (evt: FormEvent<HTMLInputElement>) => {
const target = evt.target as HTMLInputElement
const val = target.value.replace(/\D/g, '')
onValue?.(val)
let val = target.value
if (allowFloat) {
val = val.replace(/[^0-9.]/g, '').replace(/(\..*?)\..*/g, '$1')
onValue?.(val)
} else {
val = val.replace(/\D/g, '')
onValue?.(val)
}
setInnerValue(val)
}
return (
<input
value={value}
<TextInput
value={innerValue}
onInput={handleOnInput}
className="number-input"
{...itemProps}
ref={forwardedRef}
type="text"
/>
)
}
)
NumberInput.defaultProps = {
allowFloat: false,
}
export default NumberInput

View File

@@ -51,6 +51,7 @@ const Selector = (props: Props) => {
className="select-trigger"
style={{ width }}
ref={contentRef}
onKeyDown={e => e.preventDefault()}
>
<Select.Value />
<Select.Icon>

View File

@@ -12,6 +12,7 @@ const Switch = React.forwardRef<
{...itemProps}
ref={forwardedRef}
className={`switch-root ${className}`}
onKeyDown={e => e.preventDefault()}
/>
)
})

View File

@@ -1,7 +1,7 @@
.toast-viewpoint {
position: fixed;
top: 48px;
right: 0;
bottom: 48px;
right: 1.5rem;
display: flex;
flex-direction: row;
padding: 25px;

View File

@@ -0,0 +1,7 @@
import mitt from 'mitt'
export const EVENT_PROMPT = 'prompt'
const emitter = mitt()
export default emitter

View File

@@ -0,0 +1,22 @@
import { Options, useHotkeys } from 'react-hotkeys-hook'
import { useRecoilValue } from 'recoil'
import { appState } from '../store/Atoms'
const useHotKey = (
keys: string,
callback: any,
options?: Options,
deps?: any[]
) => {
const app = useRecoilValue(appState)
const ref = useHotkeys(
keys,
callback,
{ ...options, enabled: !app.disableShortCuts },
deps
)
return ref
}
export default useHotKey

View File

@@ -9,6 +9,7 @@ export enum AIModel {
ZITS = 'zits',
MAT = 'mat',
FCF = 'fcf',
SD14 = 'sd1.4',
}
export const fileState = atom<File | undefined>({
@@ -16,6 +17,89 @@ export const fileState = atom<File | undefined>({
default: undefined,
})
export interface Rect {
x: number
y: number
width: number
height: number
}
interface AppState {
disableShortCuts: boolean
isInpainting: boolean
}
export const appState = atom<AppState>({
key: 'appState',
default: {
disableShortCuts: false,
isInpainting: false,
},
})
export const propmtState = atom<string>({
key: 'promptState',
default: '',
})
export const isInpaintingState = selector({
key: 'isInpainting',
get: ({ get }) => {
const app = get(appState)
return app.isInpainting
},
set: ({ get, set }, newValue: any) => {
const app = get(appState)
set(appState, { ...app, isInpainting: newValue })
},
})
export const croperState = atom<Rect>({
key: 'croperState',
default: {
x: 0,
y: 0,
width: 512,
height: 512,
},
})
export const croperX = selector({
key: 'croperX',
get: ({ get }) => get(croperState).x,
set: ({ get, set }, newValue: any) => {
const rect = get(croperState)
set(croperState, { ...rect, x: newValue })
},
})
export const croperY = selector({
key: 'croperY',
get: ({ get }) => get(croperState).y,
set: ({ get, set }, newValue: any) => {
const rect = get(croperState)
set(croperState, { ...rect, y: newValue })
},
})
export const croperHeight = selector({
key: 'croperHeight',
get: ({ get }) => get(croperState).height,
set: ({ get, set }, newValue: any) => {
const rect = get(croperState)
set(croperState, { ...rect, height: newValue })
},
})
export const croperWidth = selector({
key: 'croperWidth',
get: ({ get }) => get(croperState).width,
set: ({ get, set }, newValue: any) => {
const rect = get(croperState)
set(croperState, { ...rect, width: newValue })
},
})
interface ToastAtomState {
open: boolean
desc: string
@@ -50,6 +134,7 @@ type ModelsHDSettings = { [key in AIModel]: HDSettings }
export interface Settings {
show: boolean
showCroper: boolean
downloadMask: boolean
graduallyInpainting: boolean
runInpaintingManually: boolean
@@ -62,6 +147,16 @@ export interface Settings {
// For ZITS
zitsWireframe: boolean
// For SD
sdMode: SDMode
sdStrength: number
sdSteps: number
sdGuidanceScale: number
sdSampler: SDSampler
sdSeed: number
sdSeedFixed: boolean // true: use sdSeed, false: random generate seed on backend
sdNumSamples: number
}
const defaultHDSettings: ModelsHDSettings = {
@@ -100,10 +195,28 @@ const defaultHDSettings: ModelsHDSettings = {
hdStrategyCropMargin: 128,
enabled: false,
},
[AIModel.SD14]: {
hdStrategy: HDStrategy.ORIGINAL,
hdStrategyResizeLimit: 768,
hdStrategyCropTrigerSize: 512,
hdStrategyCropMargin: 128,
enabled: true,
},
}
export enum SDSampler {
ddim = 'ddim',
}
export enum SDMode {
text2img = 'text2img',
img2img = 'img2img',
inpainting = 'inpainting',
}
export const settingStateDefault: Settings = {
show: false,
showCroper: false,
downloadMask: false,
graduallyInpainting: true,
runInpaintingManually: false,
@@ -114,6 +227,16 @@ export const settingStateDefault: Settings = {
ldmSampler: LDMSampler.plms,
zitsWireframe: true,
// SD
sdMode: SDMode.inpainting,
sdStrength: 0.75,
sdSteps: 50,
sdGuidanceScale: 7.5,
sdSampler: SDSampler.ddim,
sdSeed: 42,
sdSeedFixed: true,
sdNumSamples: 1,
}
const localStorageEffect =
@@ -164,3 +287,20 @@ export const hdSettingsState = selector({
})
},
})
export const isSDState = selector({
key: 'isSD',
get: ({ get }) => {
const settings = get(settingState)
return settings.model === AIModel.SD14
},
})
export const runManuallyState = selector({
key: 'runManuallyState',
get: ({ get }) => {
const settings = get(settingState)
const isSD = get(isSDState)
return settings.runInpaintingManually || isSD
},
})

View File

@@ -6,6 +6,7 @@
--page-bg-light: rgb(255, 255, 255, 0.5);
--page-text-color: #040404;
--yellow-accent: #ffcc00;
--yellow-accent-light: #ffcc0055;
--link-color: rgb(0, 0, 0);
--border-color: rgb(100, 100, 120);
--border-color-light: rgba(100, 100, 120, 0.5);
@@ -57,4 +58,6 @@
--box-shadow: inset 0 0.5px rgba(255, 255, 255, 0.1),
inset 0 1px 5px hsl(210 16.7% 97.6%), 0px 0px 0px 0.5px hsl(205 10.7% 78%),
0px 2px 1px -1px hsl(205 10.7% 78%), 0 1px hsl(205 10.7% 78%);
--croper-bg: rgba(0, 0, 0, 0.5);
}

View File

@@ -6,6 +6,7 @@
--page-bg-light: #04040488;
--page-text-color: #f9f9f9;
--yellow-accent: #ffcc00;
--yellow-accent-light: #ffcc0055;
--link-color: var(--yellow-accent);
--border-color: rgb(100, 100, 120);
--border-color-light: rgba(102, 102, 102);
@@ -55,4 +56,6 @@
--box-shadow: inset 0 0.5px rgba(255, 255, 255, 0.1),
inset 0 1px 5px hsl(195 7.1% 11%), 0px 0px 0px 0.5px hsl(207 5.6% 31.6%),
0px 2px 1px -1px hsl(207 5.6% 31.6%), 0 1px hsl(207 5.6% 31.6%);
--croper-bg: rgba(0, 0, 0, 0.5);
}

View File

@@ -9,9 +9,12 @@
@use '../components/Editor/Editor';
@use '../components/LandingPage/LandingPage';
@use '../components/Header/Header';
@use '../components/Header/PromptInput';
@use '../components/Header/ThemeChanger';
@use '../components/Shortcuts/Shortcuts';
@use '../components/Settings/Settings.scss';
@use '../components/SidePanel/SidePanel.scss';
@use '../components/Croper/Croper.scss';
// Shared
@use '../components/FileSelect/FileSelect';