diff --git a/requirements.txt b/requirements.txt index f87addc..cf61d96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ fastapi uvicorn piexif python-dotenv -Pillow \ No newline at end of file +Pillow +imageio +av \ No newline at end of file diff --git a/scripts/iib/api.py b/scripts/iib/api.py index adeeef0..d1a3ca4 100644 --- a/scripts/iib/api.py +++ b/scripts/iib/api.py @@ -1,14 +1,16 @@ from datetime import datetime, timedelta import os +from pathlib import Path import shutil import sqlite3 + from scripts.iib.tool import ( comfyui_exif_data_to_str, get_comfyui_exif_data, human_readable_size, is_img_created_by_comfyui, is_img_created_by_comfyui_with_webui_gen_info, - is_valid_image_path, + is_valid_media_path, temp_path, read_sd_webui_gen_info_from_image, get_formatted_date, @@ -28,12 +30,12 @@ from scripts.iib.tool import ( is_secret_key_required, open_file_with_default_app ) -from fastapi import FastAPI, HTTPException, Response +from fastapi import FastAPI, HTTPException, Header, Response from fastapi.staticfiles import StaticFiles import asyncio from typing import List, Optional from pydantic import BaseModel -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from PIL import Image from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware @@ -53,6 +55,7 @@ from scripts.iib.db.update_image_data import update_image_data, rebuild_image_in from scripts.iib.logger import logger from scripts.iib.seq import seq import urllib.parse +from scripts.iib.fastapi_video import range_requests_response index_html_path = os.path.join(cwd, "vue/dist/index.html") # 在app.py也被使用 @@ -493,7 +496,7 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs): encoded_filename = urllib.parse.quote(disposition.encode('utf-8')) headers['Content-Disposition'] = f"attachment; filename*=UTF-8''{encoded_filename}" - if is_path_under_parents(filename) and is_valid_image_path( + if is_path_under_parents(filename) and is_valid_media_path( filename ): # 认为永远不变,不要协商缓存了试试 headers[ @@ -508,6 +511,54 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs): media_type=media_type, headers=headers, ) + + @app.get(api_base + "/stream_video", dependencies=[Depends(verify_secret)]) + async def stream_video(path: str, request: Request): + import mimetypes + print(path) + print(request) + media_type, _ = mimetypes.guess_type(path) + return range_requests_response( + request, file_path=path, content_type=media_type + ) + + @app.get(api_base + "/video_cover", dependencies=[Depends(verify_secret)]) + async def video_cover(path: str, t: str): + check_path_trust(path) + if not temp_path: + return + # 生成缓存文件的路径 + hash_dir = hashlib.md5((path + t).encode("utf-8")).hexdigest() + hash = hash_dir + cache_dir = os.path.join(temp_path, "iib_cache", "video_cover", hash_dir) + cache_path = os.path.join(cache_dir, "cover.webp") + + # 如果缓存文件存在,则直接返回该文件 + if os.path.exists(cache_path): + print("命中缓存") + return FileResponse( + cache_path, + media_type="image/webp", + headers={"Cache-Control": "max-age=31536000", "ETag": hash}, + ) + # 如果缓存文件不存在,则生成缩略图并保存 + + import imageio.v3 as iio + frame = iio.imread( + path, + index=16, + plugin="pyav", + ) + + os.makedirs(cache_dir, exist_ok=True) + iio.imwrite(cache_path,frame, extension=".webp") + + # 返回缓存文件 + return FileResponse( + cache_path, + media_type="image/webp", + headers={"Cache-Control": "max-age=31536000", "ETag": hash}, + ) @app.post(api_base + "/send_img_path", dependencies=[Depends(verify_secret)]) async def api_set_send_img_path(path: str): @@ -728,7 +779,7 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs): @app.get(db_api_base + "/img_selected_custom_tag", dependencies=[Depends(verify_secret)]) async def get_img_selected_custom_tag(path: str): path = os.path.normpath(path) - if not is_valid_image_path(path): + if not is_valid_media_path(path): return [] conn = DataBase.get_conn() update_extra_paths(conn) diff --git a/scripts/iib/db/update_image_data.py b/scripts/iib/db/update_image_data.py index d91fc1c..ecbf4fd 100644 --- a/scripts/iib/db/update_image_data.py +++ b/scripts/iib/db/update_image_data.py @@ -6,8 +6,9 @@ from PIL import Image from scripts.iib.tool import ( read_sd_webui_gen_info_from_image, parse_generation_parameters, - is_valid_image_path, + is_valid_media_path, get_modified_date, + get_video_type, is_dev, get_comfyui_exif_data, comfyui_exif_data_to_str, @@ -22,6 +23,8 @@ from scripts.iib.logger import logger def get_exif_data(file_path): info = '' params = None + if get_video_type(file_path): + return params, info try: with Image.open(file_path) as img: if is_img_created_by_comfyui(img): @@ -62,7 +65,7 @@ def update_image_data(search_dirs: List[str], is_rebuild = False): if os.path.isdir(file_path): process_folder(file_path) - elif is_valid_image_path(file_path): + elif is_valid_media_path(file_path): img = DbImg.get(conn, file_path) if is_rebuild: parsed_params, info = get_exif_data(file_path) diff --git a/scripts/iib/fastapi_video.py b/scripts/iib/fastapi_video.py new file mode 100644 index 0000000..bdfef6f --- /dev/null +++ b/scripts/iib/fastapi_video.py @@ -0,0 +1,74 @@ +import os +from typing import BinaryIO + +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.responses import StreamingResponse + + +def send_bytes_range_requests( + file_obj: BinaryIO, start: int, end: int, chunk_size: int = 10_000 +): + """Send a file in chunks using Range Requests specification RFC7233 + + `start` and `end` parameters are inclusive due to specification + """ + with file_obj as f: + f.seek(start) + while (pos := f.tell()) <= end: + read_size = min(chunk_size, end + 1 - pos) + yield f.read(read_size) + + +def _get_range_header(range_header: str, file_size: int) -> tuple[int, int]: + def _invalid_range(): + return HTTPException( + status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, + detail=f"Invalid request range (Range:{range_header!r})", + ) + + try: + h = range_header.replace("bytes=", "").split("-") + start = int(h[0]) if h[0] != "" else 0 + end = int(h[1]) if h[1] != "" else file_size - 1 + except ValueError: + raise _invalid_range() + + if start > end or start < 0 or end > file_size - 1: + raise _invalid_range() + return start, end + + +def range_requests_response( + request: Request, file_path: str, content_type: str +): + """Returns StreamingResponse using Range Requests of a given file""" + + file_size = os.stat(file_path).st_size + range_header = request.headers.get("range") + + headers = { + "content-type": content_type, + "accept-ranges": "bytes", + "content-encoding": "identity", + "content-length": str(file_size), + "access-control-expose-headers": ( + "content-type, accept-ranges, content-length, " + "content-range, content-encoding" + ), + } + start = 0 + end = file_size - 1 + status_code = status.HTTP_200_OK + + if range_header is not None: + start, end = _get_range_header(range_header, file_size) + size = end - start + 1 + headers["content-length"] = str(size) + headers["content-range"] = f"bytes {start}-{end}/{file_size}" + status_code = status.HTTP_206_PARTIAL_CONTENT + + return StreamingResponse( + send_bytes_range_requests(open(file_path, mode="rb"), start, end), + headers=headers, + status_code=status_code, + ) diff --git a/scripts/iib/tool.py b/scripts/iib/tool.py index f91abf6..d79b78b 100644 --- a/scripts/iib/tool.py +++ b/scripts/iib/tool.py @@ -167,9 +167,17 @@ def convert_to_bytes(file_size_str): return int(size) else: raise ValueError(f"Invalid file size string '{file_size_str}'") + +def get_video_type(file_path): + video_extensions = ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv'] + file_extension = file_path[file_path.rfind('.'):].lower() + if file_extension in video_extensions: + return file_extension[1:] + else: + return None -def is_valid_image_path(path): +def is_valid_media_path(path): """ 判断给定的路径是否是图像文件 """ @@ -178,7 +186,7 @@ def is_valid_image_path(path): return False if not os.path.isfile(abs_path): # 判断是否是文件 return False - if not imghdr.what(abs_path): # 判断是否是图像文件 + if not imghdr.what(abs_path) and not get_video_type(abs_path): # 判断是否是图像文件 return False return True diff --git a/vue/src/components/ContextMenu.vue b/vue/src/components/ContextMenu.vue index b1cc676..c35f853 100644 --- a/vue/src/components/ContextMenu.vue +++ b/vue/src/components/ContextMenu.vue @@ -2,7 +2,7 @@ import type { Tag } from '@/api/db' import type { FileNodeInfo } from '@/api/files' import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface' -import { isImageFile } from '@/util' +import { isMediaFile } from '@/util' import { StarFilled, StarOutlined } from '@/icon' import { useGlobalStore } from '@/store/useGlobalStore' import { computed } from 'vue' @@ -35,7 +35,7 @@ const tags = computed(() => { {{ $t('openWithWalkMode') }} - + {{ $t('viewGenerationInfo') }} diff --git a/vue/src/components/FileItem.vue b/vue/src/components/FileItem.vue index ac1a307..a189fb5 100644 --- a/vue/src/components/FileItem.vue +++ b/vue/src/components/FileItem.vue @@ -4,16 +4,17 @@ import { useGlobalStore } from '@/store/useGlobalStore' import { fallbackImage, ok } from 'vue3-ts-util' import type { FileNodeInfo } from '@/api/files' import { isImageFile, isVideoFile } from '@/util' -import { toImageThumbnailUrl, toRawFileUrl } from '@/util/file' +import { toImageThumbnailUrl, toVideoCoverUrl, toRawFileUrl } from '@/util/file' import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface' import { computed } from 'vue' import ContextMenu from './ContextMenu.vue' import ChangeIndicator from './ChangeIndicator.vue' import { useTagStore } from '@/store/useTagStore' -import { CloseCircleOutlined, StarFilled, StarOutlined, PlayCircleFilled } from '@/icon' +import { CloseCircleOutlined, StarFilled, StarOutlined } from '@/icon' import { Tag } from '@/api/db' import { openVideoModal } from './functionalCallableComp' import type { GenDiffInfo } from '@/api/files' +import { play } from '@/icon' const global = useGlobalStore() const tagStore = useTagStore() @@ -35,19 +36,23 @@ const props = withDefaults( enableChangeIndicator?: boolean extraTags?: Tag[] }>(), - { selected: false, enableRightClickMenu: true, enableCloseIcon: false, genDiffToNext: () => ({ - empty: true, - ownFile: "", - otherFile: "", - diff: "", -}), genDiffToPrevious: () => ({ - empty: true, - ownFile: "", - otherFile: "", - diff: "", -}) } + { + selected: false, enableRightClickMenu: true, enableCloseIcon: false, genDiffToNext: () => ({ + empty: true, + ownFile: "", + otherFile: "", + diff: "", + }), genDiffToPrevious: () => ({ + empty: true, + ownFile: "", + otherFile: "", + diff: "", + }) + } ) + + const emit = defineEmits<{ 'update:showMenuIdx': [v: number], 'fileItemClick': [event: MouseEvent, file: FileNodeInfo, idx: number], @@ -85,9 +90,9 @@ const taggleLikeTag = () => { typeof idx === 'number' && emit('update:showMenuIdx', v ? idx : -1)"> @@ -124,22 +129,25 @@ const taggleLikeTag = () => { - + + src: fullScreenPreviewImageUrl, + onVisibleChange: (v: boolean, lv: boolean) => emit('previewVisibleChange', v, lv) + }" /> {{ tag.name }} - + + - + @@ -174,7 +182,6 @@ const taggleLikeTag = () => { +} + diff --git a/vue/src/components/functionalCallableComp.tsx b/vue/src/components/functionalCallableComp.tsx index d55c3c7..f0377f0 100644 --- a/vue/src/components/functionalCallableComp.tsx +++ b/vue/src/components/functionalCallableComp.tsx @@ -3,7 +3,7 @@ import { ref } from 'vue' import * as Path from '@/util/path' import { FileNodeInfo, mkdirs } from '@/api/files' import { t } from '@/i18n' -import { downloadFiles, globalEvents, toRawFileUrl } from '@/util' +import { downloadFiles, globalEvents, toRawFileUrl, toStreamVideoUrl } from '@/util' import { DownloadOutlined } from '@/icon' import { isStandalone } from '@/util/env' import { rebuildImageIndex } from '@/api/db' @@ -52,7 +52,7 @@ export const openVideoModal = (file: FileNodeInfo) => { flexDirection: 'column' }} > - + downloadFiles([toRawFileUrl(file, true)])}> {{ diff --git a/vue/src/icon/index.ts b/vue/src/icon/index.ts index 98f0630..1fd1a99 100644 --- a/vue/src/icon/index.ts +++ b/vue/src/icon/index.ts @@ -1,5 +1,7 @@ export * from '@ant-design/icons-vue' import regex from './regex.svg' +import play from './play.svg' export { - regex + regex, + play } \ No newline at end of file diff --git a/vue/src/icon/play.svg b/vue/src/icon/play.svg new file mode 100644 index 0000000..fc435ac --- /dev/null +++ b/vue/src/icon/play.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/vue/src/util/file.ts b/vue/src/util/file.ts index d3b00ca..a2c99e6 100644 --- a/vue/src/util/file.ts +++ b/vue/src/util/file.ts @@ -4,14 +4,19 @@ import { uniqBy } from 'lodash-es' const encode = encodeURIComponent export const toRawFileUrl = (file: FileNodeInfo, download = false) => - `${apiBase.value}/file?path=${encode(file.fullpath)}&t=${encode(file.date)}${ - download ? `&disposition=${encode(file.name)}` : '' + `${apiBase.value}/file?path=${encode(file.fullpath)}&t=${encode(file.date)}${download ? `&disposition=${encode(file.name)}` : '' }` export const toImageThumbnailUrl = (file: FileNodeInfo, size: string = '512x512') => `${apiBase.value}/image-thumbnail?path=${encode(file.fullpath)}&size=${size}&t=${encode( file.date )}` +export const toStreamVideoUrl = (file: FileNodeInfo) => + `${apiBase.value}/stream_video?path=${encode(file.fullpath)}` + + export const toVideoCoverUrl = (file: FileNodeInfo) => + parent.document.location.origin+ `${apiBase.value}/video_cover?path=${encode(file.fullpath)}&t=${encode(file.date)}` + export type FileTransferData = { path: string[] loc: string @@ -30,7 +35,7 @@ export const getFileTransferDataFromDragEvent = (e: DragEvent) => { export const uniqueFile = (files: FileNodeInfo[]) => uniqBy(files, 'fullpath') -export function isImageFile(filename: string): boolean { +export function isImageFile (filename: string): boolean { if (typeof filename !== 'string') { return false } @@ -39,7 +44,7 @@ export function isImageFile(filename: string): boolean { return extension !== undefined && exts.includes(`.${extension}`) } -export function isVideoFile(filename: string): boolean { +export function isVideoFile (filename: string): boolean { if (typeof filename !== 'string') { return false } @@ -50,10 +55,10 @@ export function isVideoFile(filename: string): boolean { export const isMediaFile = (file: string) => isImageFile(file) || isVideoFile(file) -export function downloadFiles(urls: string[]) { - const link = document.createElement('a'); - link.style.display = 'none'; - document.body.appendChild(link); +export function downloadFiles (urls: string[]) { + const link = document.createElement('a') + link.style.display = 'none' + document.body.appendChild(link) urls.forEach((url) => { const urlObject = new URL(url, 'https://github.com/zanllp/sd-webui-infinite-image-browsing') @@ -62,12 +67,12 @@ export function downloadFiles(urls: string[]) { if (disposition) { filename = disposition } - link.href = url; - link.download = filename; - link.click(); - }); + link.href = url + link.download = filename + link.click() + }) - document.body.removeChild(link); + document.body.removeChild(link) } export const downloadFileInfoJSON = (files: FileNodeInfo[], name?: string) => {