add file manager

This commit is contained in:
Qing 2022-12-31 21:07:08 +08:00
parent b847ded828
commit 2dd95be90d
17 changed files with 5402 additions and 5872 deletions

View File

@ -30,7 +30,8 @@
"react-dom": "^17.0.2",
"react-feather": "^2.0.10",
"react-hotkeys-hook": "^3.4.7",
"react-scripts": "4.0.3",
"react-photo-album": "^2.0.0",
"react-scripts": "5.0.1",
"react-use": "^17.3.1",
"react-zoom-pan-pinch": "^2.1.3",
"recoil": "^0.6.1",
@ -38,7 +39,7 @@
"typescript": "4.x"
},
"scripts": {
"start": "react-scripts start",
"start": "cross-env GENERATE_SOURCEMAP=false react-scripts start",
"build": "cross-env GENERATE_SOURCEMAP=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
@ -65,8 +66,8 @@
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-react": "^7.27.1",
"eslint-plugin-react-hooks": "^4.3.0",
"prettier": "^2.4.1",
"sass": "^1.49.9"
}

View File

@ -164,3 +164,16 @@ export async function postInteractiveSeg(
throw new Error(`Something went wrong: ${error}`)
}
}
export async function getMediaFile(filename: string) {
const res = await fetch(`${API_ENDPOINT}/media/${filename}`, {
method: 'GET',
})
if (res.ok) {
const blob = await res.blob()
const file = new File([blob], filename)
return file
}
const errMsg = await res.text()
throw new Error(errMsg)
}

View File

@ -0,0 +1,27 @@
.file-manager-modal {
color: var(--text-color);
height: 90%;
width: 80%;
}
.file-manager {
overflow: auto;
border-radius: 8px;
}
.react-photo-album.react-photo-album--columns {
height: 80vh;
}
.react-photo-album--photo {
border-radius: 8px;
border: 1px solid transparent;
// transform-origin: 0 0;
transition: transform 0.25s, visibility 0.25s ease-in;
&:hover {
border: 1px solid var(--border-color);
transform: scale(1.01);
}
}

View File

@ -0,0 +1,90 @@
import React, { ReactNode, useEffect, useMemo, useState } from 'react'
import PhotoAlbum, { RenderPhoto } from 'react-photo-album'
import Modal from '../shared/Modal'
interface Photo {
src: string
height: number
width: number
}
interface Filename {
name: string
height: number
width: number
}
const renderPhoto: RenderPhoto = ({
layout,
layoutOptions,
imageProps: { alt, style, ...restImageProps },
}) => (
<div
style={{
boxSizing: 'content-box',
alignItems: 'center',
}}
>
<img
alt={alt}
style={{ ...style, width: '100%', padding: 0 }}
{...restImageProps}
/>
</div>
)
interface Props {
show: boolean
onClose: () => void
onPhotoClick: (filename: string) => void
}
export default function FileManager(props: Props) {
const { show, onClose, onPhotoClick } = props
const [filenames, setFileNames] = useState<Filename[]>([])
const onClick = ({ index }: { index: number }) => {
onPhotoClick(filenames[index].name)
}
useEffect(() => {
const fetchData = async () => {
const res = await fetch('/medias')
if (res.ok) {
const newFilenames = await res.json()
setFileNames(newFilenames)
}
}
fetchData()
}, [])
const photos = useMemo(() => {
return filenames.map((filename: Filename) => {
const width = 256
const height = filename.height * (width / filename.width)
const src = `/media_thumbnail/${filename.name}?width=${width}&height=${height}`
return { src, height, width }
})
}, [filenames])
return (
<Modal
onClose={onClose}
title="Files"
className="file-manager-modal"
show={show}
>
<div className="file-manager">
<PhotoAlbum
layout="columns"
photos={photos}
renderPhoto={renderPhoto}
spacing={6}
padding={4}
onClick={onClick}
/>
</div>
</Modal>
)
}

View File

@ -1,4 +1,4 @@
import { ArrowUpTrayIcon } from '@heroicons/react/24/outline'
import { FolderIcon, PhotoIcon } from '@heroicons/react/24/outline'
import { PlayIcon } from '@radix-ui/react-icons'
import React, { useState } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
@ -9,6 +9,7 @@ import {
isSDState,
maskState,
runManuallyState,
showFileManagerState,
} from '../../store/Atoms'
import Button from '../shared/Button'
import Shortcuts from '../Shortcuts/Shortcuts'
@ -18,6 +19,7 @@ import PromptInput from './PromptInput'
import CoffeeIcon from '../CoffeeIcon/CoffeeIcon'
import emitter, { EVENT_CUSTOM_MASK } from '../../event'
import { useImage } from '../../utils'
import useHotKey from '../../hooks/useHotkey'
const Header = () => {
const isInpainting = useRecoilValue(isInpaintingState)
@ -29,6 +31,17 @@ const Header = () => {
const isSD = useRecoilValue(isSDState)
const runManually = useRecoilValue(runManuallyState)
const [openMaskPopover, setOpenMaskPopover] = useState(false)
const [showFileManager, setShowFileManager] =
useRecoilState(showFileManagerState)
useHotKey(
'f',
() => {
setShowFileManager(!showFileManager)
},
{},
[showFileManager]
)
const renderHeader = () => {
return (
@ -41,10 +54,20 @@ const Header = () => {
gap: 8,
}}
>
<Button
icon={<FolderIcon />}
style={{ border: 0 }}
toolTip="Open File Manager"
tooltipPosition="bottom"
onClick={() => {
setShowFileManager(true)
}}
/>
<label htmlFor={uploadElemId}>
<Button
icon={<ArrowUpTrayIcon />}
style={{ border: 0 }}
icon={<PhotoIcon />}
style={{ border: 0, gap: 0 }}
disabled={isInpainting}
toolTip="Upload image"
tooltipPosition="bottom"
@ -62,7 +85,6 @@ const Header = () => {
}}
accept="image/png, image/jpeg"
/>
Image
</Button>
</label>

View File

@ -67,6 +67,7 @@ export default function ShortcutsModal() {
<ShortCut content="Toggle Dark Mode" keys={['Shift', 'D']} />
<ShortCut content="Toggle Hotkeys Dialog" keys={['H']} />
<ShortCut content="Toggle Settings Dialog" keys={['S']} />
<ShortCut content="Toggle File Manager" keys={['F']} />
</div>
</Modal>
)

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import Editor from './Editor/Editor'
import ShortcutsModal from './Shortcuts/ShortcutsModal'
@ -10,15 +10,18 @@ import {
isPaintByExampleState,
isSDState,
settingState,
showFileManagerState,
toastState,
} from '../store/Atoms'
import {
currentModel,
getMediaFile,
modelDownloaded,
switchModel,
} from '../adapters/inpainting'
import SidePanel from './SidePanel/SidePanel'
import PESidePanel from './SidePanel/PESidePanel'
import FileManager from './FileManager/FileManager'
const Workspace = () => {
const [file, setFile] = useRecoilState(fileState)
@ -27,6 +30,9 @@ const Workspace = () => {
const isSD = useRecoilValue(isSDState)
const isPaintByExample = useRecoilValue(isPaintByExampleState)
const [showFileManager, setShowFileManager] =
useRecoilState(showFileManagerState)
const onSettingClose = async () => {
const curModel = await currentModel().then(res => res.text())
if (curModel === settings.model) {
@ -92,6 +98,17 @@ const Workspace = () => {
<>
{isSD ? <SidePanel /> : <></>}
{isPaintByExample ? <PESidePanel /> : <></>}
<FileManager
show={showFileManager}
onClose={() => {
setShowFileManager(false)
}}
onPhotoClick={async (filename: string) => {
const newFile = await getMediaFile(filename)
setFile(newFile)
setShowFileManager(false)
}}
/>
<Editor />
<SettingModal onClose={onSettingClose} />
<ShortcutsModal />

View File

@ -32,7 +32,7 @@
grid-auto-rows: max-content;
row-gap: 1rem;
place-self: center;
padding: 2rem;
padding: 25px;
border-radius: 0.95rem;
&:focus {

View File

@ -41,6 +41,7 @@ interface AppState {
isInteractiveSeg: boolean
isInteractiveSegRunning: boolean
interactiveSegClicks: number[][]
showFileManager: boolean
}
export const appState = atom<AppState>({
@ -53,6 +54,7 @@ export const appState = atom<AppState>({
isInteractiveSeg: false,
isInteractiveSegRunning: false,
interactiveSegClicks: [],
showFileManager: false,
},
})
@ -78,6 +80,18 @@ export const isInpaintingState = selector({
},
})
export const showFileManagerState = selector({
key: 'showFileManager',
get: ({ get }) => {
const app = get(appState)
return app.showFileManager
},
set: ({ get, set }, newValue: any) => {
const app = get(appState)
set(appState, { ...app, showFileManager: newValue })
},
})
export const fileState = selector({
key: 'fileState',
get: ({ get }) => {

View File

@ -7,6 +7,7 @@
// App
@use './App';
@use '../components/Editor/Editor';
@use '../components/FileManager/FileManager';
@use '../components/LandingPage/LandingPage';
@use '../components/Header/Header';
@use '../components/Header/PromptInput';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
from .file_manager import FileManager

View File

@ -0,0 +1,186 @@
# Copy from https://github.com/silentsokolov/flask-thumbnails/blob/master/flask_thumbnails/thumbnail.py
import os
from functools import lru_cache
from io import BytesIO
from PIL import Image, ImageOps, PngImagePlugin
LARGE_ENOUGH_NUMBER = 100
PngImagePlugin.MAX_TEXT_CHUNK = LARGE_ENOUGH_NUMBER * (1024**2)
from .storage_backends import FilesystemStorageBackend
from .utils import aspect_to_string, generate_filename, glob_img
class FileManager:
def __init__(self, app=None):
self.app = app
self._default_root_directory = "media"
self._default_thumbnail_directory = "media"
self._default_root_url = "/"
self._default_thumbnail_root_url = "/"
self._default_format = "JPEG"
if app is not None:
self.init_app(app)
def init_app(self, app):
if self.app is None:
self.app = app
app.thumbnail_instance = self
if not hasattr(app, "extensions"):
app.extensions = {}
if "thumbnail" in app.extensions:
raise RuntimeError("Flask-thumbnail extension already initialized")
app.extensions["thumbnail"] = self
app.config.setdefault("THUMBNAIL_MEDIA_ROOT", self._default_root_directory)
app.config.setdefault("THUMBNAIL_MEDIA_THUMBNAIL_ROOT", self._default_thumbnail_directory)
app.config.setdefault("THUMBNAIL_MEDIA_URL", self._default_root_url)
app.config.setdefault("THUMBNAIL_MEDIA_THUMBNAIL_URL", self._default_thumbnail_root_url)
app.config.setdefault("THUMBNAIL_DEFAULT_FORMAT", self._default_format)
@property
def root_directory(self):
path = self.app.config["THUMBNAIL_MEDIA_ROOT"]
if os.path.isabs(path):
return path
else:
return os.path.join(self.app.root_path, path)
@property
def thumbnail_directory(self):
path = self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"]
if os.path.isabs(path):
return path
else:
return os.path.join(self.app.root_path, path)
@property
def root_url(self):
return self.app.config["THUMBNAIL_MEDIA_URL"]
@property
@lru_cache()
def media_names(self):
names = sorted([it.name for it in glob_img(self.root_directory)])
res = []
for name in names:
img = Image.open(os.path.join(self.root_directory, name))
res.append({"name": name, "height": img.height, "width": img.width})
return res
@property
def thumbnail_url(self):
return self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_URL"]
def get_thumbnail(self, original_filename, width, height, **options):
storage = FilesystemStorageBackend(self.app)
crop = options.get("crop", "fit")
background = options.get("background")
quality = options.get("quality", 90)
original_path, original_filename = os.path.split(original_filename)
original_filepath = os.path.join(self.root_directory, original_path, original_filename)
image = Image.open(BytesIO(storage.read(original_filepath)))
# keep ratio resize
if width is not None:
height = int(image.height * width / image.width)
else:
width = int(image.width * height / image.height)
thumbnail_size = (width, height)
thumbnail_filename = generate_filename(
original_filename, aspect_to_string(thumbnail_size), crop, background, quality
)
thumbnail_filepath = os.path.join(
self.thumbnail_directory, original_path, thumbnail_filename
)
thumbnail_url = os.path.join(self.thumbnail_url, original_path, thumbnail_filename)
if storage.exists(thumbnail_filepath):
return thumbnail_url, (width, height)
try:
image.load()
except (IOError, OSError):
self.app.logger.warning("Thumbnail not load image: %s", original_filepath)
return thumbnail_url, (width, height)
# get original image format
options["format"] = options.get("format", image.format)
image = self._create_thumbnail(image, thumbnail_size, crop, background=background)
raw_data = self.get_raw_data(image, **options)
storage.save(thumbnail_filepath, raw_data)
return thumbnail_url, (width, height)
def get_raw_data(self, image, **options):
data = {
"format": self._get_format(image, **options),
"quality": options.get("quality", 90),
}
_file = BytesIO()
image.save(_file, **data)
return _file.getvalue()
@staticmethod
def colormode(image, colormode="RGB"):
if colormode == "RGB" or colormode == "RGBA":
if image.mode == "RGBA":
return image
if image.mode == "LA":
return image.convert("RGBA")
return image.convert(colormode)
if colormode == "GRAY":
return image.convert("L")
return image.convert(colormode)
@staticmethod
def background(original_image, color=0xFF):
size = (max(original_image.size),) * 2
image = Image.new("L", size, color)
image.paste(
original_image,
tuple(map(lambda x: (x[0] - x[1]) / 2, zip(size, original_image.size))),
)
return image
def _get_format(self, image, **options):
if options.get("format"):
return options.get("format")
if image.format:
return image.format
return self.app.config["THUMBNAIL_DEFAULT_FORMAT"]
def _create_thumbnail(self, image, size, crop="fit", background=None):
try:
resample = Image.Resampling.LANCZOS
except AttributeError: # pylint: disable=raise-missing-from
resample = Image.ANTIALIAS
if crop == "fit":
image = ImageOps.fit(image, size, resample)
else:
image = image.copy()
image.thumbnail(size, resample=resample)
if background is not None:
image = self.background(image)
image = self.colormode(image)
return image

View File

@ -0,0 +1,46 @@
# Copy from https://github.com/silentsokolov/flask-thumbnails/blob/master/flask_thumbnails/storage_backends.py
import errno
import os
from abc import ABC, abstractmethod
class BaseStorageBackend(ABC):
def __init__(self, app=None):
self.app = app
@abstractmethod
def read(self, filepath, mode="rb", **kwargs):
raise NotImplementedError
@abstractmethod
def exists(self, filepath):
raise NotImplementedError
@abstractmethod
def save(self, filepath, data):
raise NotImplementedError
class FilesystemStorageBackend(BaseStorageBackend):
def read(self, filepath, mode="rb", **kwargs):
with open(filepath, mode) as f: # pylint: disable=unspecified-encoding
return f.read()
def exists(self, filepath):
return os.path.exists(filepath)
def save(self, filepath, data):
directory = os.path.dirname(filepath)
if not os.path.exists(directory):
try:
os.makedirs(directory)
except OSError as e:
if e.errno != errno.EEXIST:
raise
if not os.path.isdir(directory):
raise IOError("{} is not a directory".format(directory))
with open(filepath, "wb") as f:
f.write(data)

View File

@ -0,0 +1,67 @@
# Copy from: https://github.com/silentsokolov/flask-thumbnails/blob/master/flask_thumbnails/utils.py
import importlib
import os
from pathlib import Path
from typing import Union
def generate_filename(original_filename, *options):
name, ext = os.path.splitext(original_filename)
for v in options:
if v:
name += "_%s" % v
name += ext
return name
def parse_size(size):
if isinstance(size, int):
# If the size parameter is a single number, assume square aspect.
return [size, size]
if isinstance(size, (tuple, list)):
if len(size) == 1:
# If single value tuple/list is provided, exand it to two elements
return size + type(size)(size)
return size
try:
thumbnail_size = [int(x) for x in size.lower().split("x", 1)]
except ValueError:
raise ValueError( # pylint: disable=raise-missing-from
"Bad thumbnail size format. Valid format is INTxINT."
)
if len(thumbnail_size) == 1:
# If the size parameter only contains a single integer, assume square aspect.
thumbnail_size.append(thumbnail_size[0])
return thumbnail_size
def aspect_to_string(size):
if isinstance(size, str):
return size
return "x".join(map(str, size))
IMG_SUFFIX = {'.jpg', '.jpeg', '.png'}
def glob_img(p: Union[Path, str], recursive: bool = False):
p = Path(p)
if p.is_file() and p.suffix in IMG_SUFFIX:
yield p
else:
if recursive:
files = Path(p).glob("**/*.*")
else:
files = Path(p).glob("*.*")
for it in files:
if it.suffix not in IMG_SUFFIX:
continue
yield it

View File

@ -1,6 +1,9 @@
import os
import imghdr
import argparse
from pathlib import Path
from loguru import logger
def parse_args():
@ -48,7 +51,12 @@ def parse_args():
help="Set window size for GUI",
)
parser.add_argument(
"--input", type=str, help="Path to image you want to load by default"
"--input", type=str,
help="If input is image, it will be load by default. If input is directory, all images will be loaded to file manager"
)
parser.add_argument(
"--output-dir", type=str,
help="Only required when --input is directory. Output directory for all processed images"
)
parser.add_argument("--disable-model-switch", action="store_true", help="Disable model switch in frontend")
parser.add_argument("--debug", action="store_true")
@ -57,8 +65,20 @@ def parse_args():
if args.input is not None:
if not os.path.exists(args.input):
parser.error(f"invalid --input: {args.input} not exists")
if imghdr.what(args.input) is None:
parser.error(f"invalid --input: {args.input} is not a valid image file")
if os.path.isfile(args.input):
if imghdr.what(args.input) is None:
parser.error(f"invalid --input: {args.input} is not a valid image file")
else:
if args.output_dir is None:
parser.error(f"invalid --input: {args.input} is a directory, --output-dir is required")
else:
output_dir = Path(args.output_dir)
if not output_dir.exists():
logger.info(f"Creating output directory: {output_dir}")
output_dir.mkdir(parents=True)
else:
if not output_dir.is_dir():
parser.error(f"invalid --output-dir: {output_dir} is not a directory")
if args.model == 'sd1.5' and not args.sd_run_local:
if not args.hf_access_token.startswith("hf_"):

View File

@ -20,6 +20,7 @@ from loguru import logger
from lama_cleaner.interactive_seg import InteractiveSeg, Click
from lama_cleaner.model_manager import ModelManager
from lama_cleaner.schema import Config
from lama_cleaner.file_manager import FileManager
try:
torch._C._jit_override_can_fuse_on_cpu(False)
@ -29,7 +30,8 @@ try:
except:
pass
from flask import Flask, request, send_file, cli, make_response
from flask import Flask, request, send_file, cli, make_response, send_from_directory, jsonify
from flask_caching import Cache
# Disable ability for Flask to display warning about using a development server in a production environment.
# https://gist.github.com/jerblack/735b9953ba1ab6234abb43174210d356
@ -65,15 +67,15 @@ class NoFlaskwebgui(logging.Filter):
logging.getLogger("werkzeug").addFilter(NoFlaskwebgui())
cache = Cache(config={'CACHE_TYPE': 'SimpleCache'}, with_jinja2_ext=False)
app = Flask(__name__, static_folder=os.path.join(BUILD_DIR, "static"))
app.config["JSON_AS_ASCII"] = False
CORS(app, expose_headers=["Content-Disposition"])
# MAX_BUFFER_SIZE = 50 * 1000 * 1000 # 50 MB
# async_mode 优先级: eventlet/gevent_uwsgi/gevent/threading
# only threading works on macOS
# socketio = SocketIO(app, max_http_buffer_size=MAX_BUFFER_SIZE, async_mode='threading')
cache.init_app(app)
model: ModelManager = None
thumb = FileManager(app)
interactive_seg_model: InteractiveSeg = None
device = None
input_image_path: str = None
@ -93,6 +95,38 @@ def diffuser_callback(i, t, latents):
# socketio.emit('diffusion_step', {'diffusion_step': step})
@app.route("/medias")
def medias():
# all images in input folder
return jsonify(thumb.media_names), 200
@app.route('/media/<filename>')
def media_file(filename):
return send_from_directory(app.config['THUMBNAIL_MEDIA_ROOT'], filename)
@app.route('/media_thumbnail/<filename>')
def media_thumbnail_file(filename):
args = request.args
width = args.get('width')
height = args.get('height')
if width is None and height is None:
width = 256
if width:
width = int(float(width))
if height:
height = int(float(height))
thumb_filename, (width, height) = thumb.get_thumbnail(filename, width, height)
thumb_filepath = f"{app.config['THUMBNAIL_MEDIA_THUMBNAIL_ROOT']}{thumb_filename}"
response = make_response(send_file(thumb_filepath))
response.headers["X-Width"] = str(width)
response.headers["X-Height"] = str(height)
return response
@app.route("/inpaint", methods=["POST"])
def process():
input = request.files
@ -294,12 +328,17 @@ def main(args):
global is_desktop
device = torch.device(args.device)
input_image_path = args.input
is_disable_model_switch = args.disable_model_switch
is_desktop = args.gui
if is_disable_model_switch:
logger.info(f"Start with --disable-model-switch, model switch on frontend is disable")
if os.path.isdir(args.input):
app.config["THUMBNAIL_MEDIA_ROOT"] = args.input
app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"] = os.path.join(args.output_dir, 'thumbnails')
else:
input_image_path = args.input
model = ModelManager(
name=args.model,
device=device,