diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..0465677e
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,28 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
+{
+ "name": "Node.js & TypeScript",
+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
+ "image": "mcr.microsoft.com/devcontainers/typescript-node:20",
+ "features": {
+ "ghcr.io/devcontainers/features/node:1": {
+ "version": "latest",
+ "nvmVersion": "latest"
+ }
+ }
+
+ // Features to add to the dev container. More info: https://containers.dev/features.
+ // "features": {},
+
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ // "forwardPorts": [],
+
+ // Use 'postCreateCommand' to run commands after the container is created.
+ // "postCreateCommand": "yarn install",
+
+ // Configure tool-specific properties.
+ // "customizations": {},
+
+ // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+ // "remoteUser": "root"
+}
diff --git a/.env.sample b/.env.sample
index a51af8a1..db8dd629 100644
--- a/.env.sample
+++ b/.env.sample
@@ -1,5 +1,3 @@
-VITE_ADSENSE_PUB_ID =
-VITE_GOOGLE_ANALYTICS_ID =
-VITE_GOOGLE_SEARCH_CONSOLE_VERIFICATION =
-VITE_PXIMG_BASEURL_I = /-/
-VITE_PXIMG_BASEURL_S = /~/
+NUXT_ADSENSE_PUB_ID =
+NUXT_GOOGLE_ANALYTICS_ID =
+NUXT_GOOGLE_SEARCH_CONSOLE_VERIFICATION =
diff --git a/.gitignore b/.gitignore
index ab3bd2ed..fa31c087 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,4 +108,5 @@ dist
dev-test
*.dev.*
.vercel
+.output
.vs
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 4e713441..9e22a673 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,3 @@
{
- "i18n-ally.localesPaths": [
- "src/locales"
- ]
-}
\ No newline at end of file
+ "i18n-ally.localesPaths": ["i18n/locales"]
+}
diff --git a/.vscode/vue.code-snippets b/.vscode/vue.code-snippets
deleted file mode 100644
index abd10498..00000000
--- a/.vscode/vue.code-snippets
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "Init vue components": {
- "scope": "vue",
- "prefix": "vue",
- "body": [
- "",
- "$0",
- "",
- "",
- "",
- "",
- ""
- ],
- "description": "Init vue components"
- }
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index 1f0dcb8c..1ee1775e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-
+
-
+
Pixiv Service Proxy
diff --git a/api/http.ts b/api/http.ts
deleted file mode 100644
index f74dd74f..00000000
--- a/api/http.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { VercelRequest, VercelResponse } from '@vercel/node'
-import escapeRegExp from 'lodash.escaperegexp'
-import { ajax } from './utils.js'
-
-export default async function (req: VercelRequest, res: VercelResponse) {
- if (!isAccepted(req)) {
- return res.status(403).send('403')
- }
-
- try {
- const { __PREFIX, __PATH } = req.query
- const { data } = await ajax({
- method: req.method ?? 'GET',
- url: `/${encodeURI(`${__PREFIX}${__PATH ? '/' + __PATH : ''}`)}`,
- params: req.query ?? {},
- data: req.body || undefined,
- headers: req.headers as Record
,
- })
- res.status(200).send(data)
- } catch (e: any) {
- res.status(e?.response?.status || 500).send(e?.response?.data || e)
- }
-}
-
-function isAccepted(req: VercelRequest) {
- const { UA_BLACKLIST = '[]' } = process.env
- try {
- const list: string[] = JSON.parse(UA_BLACKLIST)
- const ua = req.headers['user-agent'] ?? ''
- return (
- !!ua &&
- Array.isArray(list) &&
- (list.length > 0
- ? !new RegExp(
- `(${list.map((str) => escapeRegExp(str)).join('|')})`,
- 'gi'
- ).test(ua)
- : true)
- )
- } catch (e) {
- return false
- }
-}
diff --git a/api/image.ts b/api/image.ts
deleted file mode 100644
index 8c87b104..00000000
--- a/api/image.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { VercelRequest, VercelResponse } from '@vercel/node'
-import axios from 'axios'
-import { USER_AGENT } from './utils.js'
-
-export default async (req: VercelRequest, res: VercelResponse) => {
- const { __PREFIX, __PATH } = req.query
- if (!__PREFIX || !__PATH) {
- return res.status(400).send({ message: 'Missing param(s)' })
- }
-
- switch (__PREFIX) {
- case '~': {
- return axios
- .get(`https://s.pximg.net/${__PATH}`, {
- responseType: 'arraybuffer',
- headers: {
- referer: 'https://www.pixiv.net/',
- 'user-agent': USER_AGENT,
- },
- })
- .then(
- ({ data, headers }) => {
- res.setHeader('Content-Type', headers['content-type'])
- res.setHeader(
- 'Cache-Control',
- `public, max-age=${12 * 60 * 60 * 3600}`
- )
- res.status(200).send(Buffer.from(data))
- },
- (err) => {
- return res
- .status(err?.response?.status || 500)
- .send(err?.response?.data || err)
- }
- )
- }
- default:
- return res.status(400).send({ message: 'Invalid request' })
- }
-}
diff --git a/api/random.ts b/api/random.ts
deleted file mode 100644
index 51bdeed7..00000000
--- a/api/random.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { VercelRequest, VercelResponse } from '@vercel/node'
-import { formatInTimeZone } from 'date-fns-tz'
-import { PXIMG_BASEURL_I, ajax } from './utils.js'
-import { Artwork } from '../src/types/Artworks.js'
-
-type ArtworkOrAd = Artwork | { isAdContainer: boolean }
-
-export default async (req: VercelRequest, res: VercelResponse) => {
- const requestImage =
- (req.headers.accept?.includes('image') || req.query.format === 'image') &&
- req.query.format !== 'json'
- try {
- const data: { illusts?: ArtworkOrAd[] } = (
- await ajax({
- url: '/ajax/illust/discovery',
- params: {
- mode: req.query.mode ?? 'safe',
- max: requestImage ? '1' : req.query.max ?? '18',
- },
- headers: req.headers,
- })
- ).data
- const illusts = (data.illusts ?? []).filter((value): value is Artwork =>
- Object.keys(value).includes('id')
- )
- illusts.forEach((value) => {
- const middle = `img/${formatInTimeZone(
- value.updateDate,
- 'Asia/Tokyo',
- 'yyyy/MM/dd/HH/mm/ss'
- )}/${value.id}`
- value.urls = {
- mini: `${PXIMG_BASEURL_I}c/48x48/img-master/${middle}_p0_square1200.jpg`,
- thumb: `${PXIMG_BASEURL_I}c/250x250_80_a2/img-master/${middle}_p0_square1200.jpg`,
- small: `${PXIMG_BASEURL_I}c/540x540_70/img-master/${middle}_p0_master1200.jpg`,
- regular: `${PXIMG_BASEURL_I}img-master/${middle}_p0_master1200.jpg`,
- original: `${PXIMG_BASEURL_I}img-original/${middle}_p0.jpg`,
- }
- })
- if (requestImage) {
- res.redirect(illusts[0].urls.regular)
- return
- } else {
- res.send(illusts)
- return
- }
- } catch (e: any) {
- res.status(e?.response?.status ?? 500).send(e?.response?.data ?? e)
- }
-}
diff --git a/api/user.ts b/api/user.ts
deleted file mode 100644
index 271e45e5..00000000
--- a/api/user.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { VercelRequest, VercelResponse } from '@vercel/node'
-import { CheerioAPI, load } from 'cheerio'
-import { ajax, replacePximgUrlsInObject } from './utils.js'
-
-export default async (req: VercelRequest, res: VercelResponse) => {
- const token = req.cookies.PHPSESSID || req.query.token
- if (!token) {
- return res.status(403).send({ message: '未配置用户密钥' })
- }
-
- ajax
- .get('/', { params: req.query, headers: req.headers })
- .then(async ({ data }) => {
- const $ = load(data)
-
- let meta: { userData: any; token: string }
- const $legacyGlobalMeta = $('meta[name="global-data"]')
- const $nextDataScript = $('script#__NEXT_DATA__')
-
- try {
- if ($legacyGlobalMeta.length > 0) {
- meta = resolveLegacyGlobalMeta($)
- } else if ($nextDataScript.length > 0) {
- meta = resolveNextData($)
- } else {
- throw new Error('未知的元数据类型', {
- cause: {
- error: new TypeError('No valid resolver found'),
- meta: null,
- },
- })
- }
- } catch (error: any) {
- return res.status(401).send({
- message: error.message,
- cause: error.cause,
- })
- }
-
- res.setHeader('cache-control', 'no-cache')
- res.setHeader(
- 'set-cookie',
- `CSRFTOKEN=${meta.token}; path=/; secure; sameSite=Lax`
- )
- res.send(replacePximgUrlsInObject(meta))
- })
- .catch((err) => {
- return res
- .status(err?.response?.status || 500)
- .send(err?.response?.data || err)
- })
-}
-
-function resolveLegacyGlobalMeta($: CheerioAPI): {
- userData: any
- token: string
-} {
- const $meta = $('meta[name="global-data"]')
- if ($meta.length === 0 || !$meta.attr('content')) {
- throw new Error('无效的用户密钥', {
- cause: {
- error: new TypeError('No global-data meta found'),
- meta: $meta.prop('outerHTML'),
- },
- })
- }
-
- let meta: any
- try {
- meta = JSON.parse($meta.attr('content') as string)
- } catch (error) {
- throw new Error('解析元数据时出错', {
- cause: {
- error,
- meta: $meta.attr('content'),
- },
- })
- }
-
- if (!meta.userData) {
- throw new Error('无法获取登录状态', {
- cause: {
- error: new TypeError('userData is not defined'),
- meta,
- },
- })
- }
-
- return {
- userData: meta.userData,
- token: meta.token || '',
- }
-}
-
-function resolveNextData($: CheerioAPI): {
- userData: any
- token: string
-} {
- const $nextDataScript = $('script#__NEXT_DATA__')
- if ($nextDataScript.length === 0) {
- throw new Error('无法获取元数据', {
- cause: {
- error: new TypeError('No #__NEXT_DATA__ script found'),
- meta: null,
- },
- })
- }
-
- let nextData: any
- let perloadState: any
- try {
- nextData = JSON.parse($nextDataScript.text())
- perloadState = JSON.parse(
- nextData?.props?.pageProps?.serverSerializedPreloadedState
- )
- } catch (error) {
- throw new Error('解析元数据时出错', {
- cause: {
- error,
- meta: $nextDataScript.text(),
- },
- })
- }
-
- const userData = perloadState?.userData?.self
- if (!userData) {
- throw new Error('意料外的元数据', {
- cause: {
- error: new TypeError('userData is not defined'),
- meta: nextData,
- },
- })
- }
-
- const token = perloadState?.api?.token || ''
- return { userData, token }
-}
diff --git a/api/utils.ts b/api/utils.ts
deleted file mode 100644
index b26b3ea1..00000000
--- a/api/utils.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { VercelRequest, VercelResponse } from '@vercel/node'
-import axios from 'axios'
-import colors from 'picocolors'
-
-// HTTP handler
-export default async function (req: VercelRequest, res: VercelResponse) {
- res.status(404).send({
- error: true,
- message: 'Not Found',
- body: null,
- })
-}
-
-export const PROD = process.env.NODE_ENV === 'production'
-export const DEV = !PROD
-export const USER_AGENT =
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0'
-export const PXIMG_BASEURL_I = (() => {
- const i = process.env.VITE_PXIMG_BASEURL_I
- return i ? i.replace(/\/$/, '') + '/' : 'https://i.pximg.net/'
-})()
-export const PXIMG_BASEURL_S = (() => {
- const s = process.env.VITE_PXIMG_BASEURL_S
- return s ? s.replace(/\/$/, '') + '/' : 'https://s.pximg.net/'
-})()
-
-export class CookieUtils {
- static toJSON(raw: string) {
- return Object.fromEntries(new URLSearchParams(raw.replace(/;\s*/g, '&')))
- }
- static toString(obj: any) {
- return Object.keys(obj)
- .map((i) => `${i}=${obj[i]}`)
- .join(';')
- }
-}
-
-export const ajax = axios.create({
- baseURL: 'https://www.pixiv.net/',
- params: {},
- headers: {
- 'user-agent': USER_AGENT,
- },
- timeout: 9 * 1000,
-})
-ajax.interceptors.request.use((ctx) => {
- // 去除内部参数
- ctx.params = ctx.params || {}
- delete ctx.params.__PATH
- delete ctx.params.__PREFIX
-
- const cookies = CookieUtils.toJSON(ctx.headers.cookie || '')
- const csrfToken = ctx.headers['x-csrf-token'] ?? cookies.CSRFTOKEN ?? ''
- // 强制覆写部分 headers
- ctx.headers = ctx.headers || {}
- ctx.headers.host = 'www.pixiv.net'
- ctx.headers.origin = 'https://www.pixiv.net'
- ctx.headers.referer = 'https://www.pixiv.net/'
- ctx.headers['user-agent'] = USER_AGENT
- ctx.headers['accept-language'] ??=
- 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6'
- csrfToken && (ctx.headers['x-csrf-token'] = csrfToken)
-
- if (DEV) {
- console.info(
- colors.green(`[${ctx.method?.toUpperCase()}] <`),
- colors.cyan(ctx.url || '')
- )
- console.info({
- params: ctx.params,
- data: ctx.data,
- cookies,
- })
- }
-
- return ctx
-})
-ajax.interceptors.response.use((ctx) => {
- typeof ctx.data === 'object' &&
- (ctx.data = replacePximgUrlsInObject(ctx.data?.body ?? ctx.data))
- if (DEV) {
- const out: string =
- typeof ctx.data === 'object'
- ? JSON.stringify(ctx.data, null, 2)
- : ctx.data.toString().trim()
- console.info(
- colors.green('[SEND] >'),
- colors.cyan(ctx.request?.path?.replace('https://www.pixiv.net', '')),
- `\n${colors.yellow(typeof ctx.data)} ${
- out.length >= 200 ? out.slice(0, 200).trim() + '\n...' : out
- }`
- )
- }
- return ctx
-})
-
-export function replacePximgUrlsInString(str: string): string {
- if (!str.includes('pximg.net')) return str
- return str
- .replaceAll('https://i.pximg.net/', PXIMG_BASEURL_I)
- .replaceAll('https://s.pximg.net/', PXIMG_BASEURL_S)
-}
-
-export function replacePximgUrlsInObject(
- obj: Record | string
-): Record | string {
- if (typeof obj === 'string') return replacePximgUrlsInString(obj)
-
- return deepReplaceString(obj, replacePximgUrlsInString)
-}
-
-function isObject(value: any): value is Record {
- return typeof value === 'object' && value !== null
-}
-
-export function deepReplaceString(
- obj: T,
- replacer: (value: string) => string
-): T {
- if (Array.isArray(obj)) {
- return obj.map((value) =>
- deepReplaceString(value, replacer)
- ) as unknown as T
- } else if (isObject(obj)) {
- if (
- ['arraybuffer', 'blob', 'formdata'].includes(
- obj.constructor.name.toLowerCase()
- )
- ) {
- return obj
- }
- const result: Record = {}
- for (const [key, value] of Object.entries(obj)) {
- result[key] = deepReplaceString(value, replacer)
- }
- return result as T
- } else if (typeof obj === 'string') {
- return replacer(obj) as unknown as T
- }
- return obj
-}
-
-export function safelyStringify(value: any, space?: number) {
- const visited = new WeakSet()
-
- const replacer = (key: string, val: any) => {
- // 处理 BigInt
- if (typeof val === 'bigint') {
- return val.toString()
- }
-
- // 处理 Set
- if (val instanceof Set) {
- return Array.from(val)
- }
-
- // 处理 Map
- if (val instanceof Map) {
- return Array.from(val.entries())
- }
-
- // 处理 function
- if (typeof val === 'function') {
- return val.toString()
- }
-
- // 处理自循环引用
- if (typeof val === 'object' && val !== null) {
- if (visited.has(val)) {
- return ''
- }
- visited.add(val)
- }
-
- return val
- }
-
- return JSON.stringify(value, replacer, space)
-}
-
-JSON.safelyStringify = safelyStringify
-declare global {
- interface JSON {
- safelyStringify: typeof safelyStringify
- }
-}
diff --git a/app/app.config.ts b/app/app.config.ts
new file mode 100644
index 00000000..2d8fbc69
--- /dev/null
+++ b/app/app.config.ts
@@ -0,0 +1,15 @@
+import { version } from '../package.json'
+
+export default defineAppConfig({
+ version,
+ githubOwner: 'FreeNowOrg',
+ githubRepo: 'PixivNow',
+ githubUrl: 'https://github.com/FreeNowOrg/PixivNow',
+ projectName: 'PixivNow',
+ projectTagline: 'Enjoy Pixiv Now (pixiv.js.org)',
+ imageCacheSeconds: 12 * 60 * 60 * 1000,
+ siteEnv:
+ process.env.NODE_ENV === 'development' || version.includes('-')
+ ? 'development'
+ : 'production',
+})
diff --git a/app/app.vue b/app/app.vue
new file mode 100644
index 00000000..d5e48983
--- /dev/null
+++ b/app/app.vue
@@ -0,0 +1,65 @@
+
+#app-full-container(
+ :data-env='config.public.mode',
+ :data-locale='routeLocale',
+ :data-route='routeName'
+)
+ NuxtLayout
+ NaiveuiProvider
+ LazySiteHeader
+ LazySideNavBody
+ main: article: NuxtPage(tag='main')
+ LazySiteFooter
+ NProgress
+
+
+
+
+
diff --git a/src/styles/animate.sass b/app/assets/styles/animate.sass
similarity index 100%
rename from src/styles/animate.sass
rename to app/assets/styles/animate.sass
diff --git a/src/styles/elements.sass b/app/assets/styles/elements.sass
similarity index 100%
rename from src/styles/elements.sass
rename to app/assets/styles/elements.sass
diff --git a/src/styles/formats.sass b/app/assets/styles/formats.sass
similarity index 100%
rename from src/styles/formats.sass
rename to app/assets/styles/formats.sass
diff --git a/src/styles/index.sass b/app/assets/styles/index.sass
similarity index 97%
rename from src/styles/index.sass
rename to app/assets/styles/index.sass
index a8722b3e..57547ac4 100644
--- a/src/styles/index.sass
+++ b/app/assets/styles/index.sass
@@ -12,7 +12,7 @@ body
*
box-sizing: border-box
-#app
+#app-full-container
font-family: Avenir, Helvetica, Arial, sans-serif
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
diff --git a/src/styles/variables.sass b/app/assets/styles/variables.sass
similarity index 100%
rename from src/styles/variables.sass
rename to app/assets/styles/variables.sass
diff --git a/src/components/ArtTag.vue b/app/components/ArtTag.vue
similarity index 71%
rename from src/components/ArtTag.vue
rename to app/components/ArtTag.vue
index 3747fc42..8850683c 100644
--- a/src/components/ArtTag.vue
+++ b/app/components/ArtTag.vue
@@ -1,6 +1,6 @@
NTag.artwork-tag(
- @click='$router.push({ name: "search", params: { keyword: tag, p: 1 } })'
+ @click='router.push({ name: "search", params: { keyword: tag, p: 1 } })'
type='info'
) {{ '#' }}{{ tag }}
@@ -8,6 +8,8 @@ NTag.artwork-tag(
diff --git a/src/components/ArtworksList/ArtworksByUser.vue b/app/components/Artwork/ArtworksByUser.vue
similarity index 78%
rename from src/components/ArtworksList/ArtworksByUser.vue
rename to app/components/Artwork/ArtworksByUser.vue
index 5652b106..a33625d8 100644
--- a/src/components/ArtworksList/ArtworksByUser.vue
+++ b/app/components/Artwork/ArtworksByUser.vue
@@ -19,21 +19,14 @@
diff --git a/src/components/AuthorCard.vue b/app/components/AuthorCard.vue
similarity index 71%
rename from src/components/AuthorCard.vue
rename to app/components/AuthorCard.vue
index 59c05392..a57ef452 100644
--- a/src/components/AuthorCard.vue
+++ b/app/components/AuthorCard.vue
@@ -3,12 +3,12 @@
.author-inner(v-if='user')
.flex-center
.left
- RouterLink(:to='"/users/" + user.userId')
+ NuxtLink(:to='"/users/" + user.userId')
img(:src='user.imageBig' alt='')
.right
.flex
h4.plain
- RouterLink(:to='"/users/" + user.userId') {{ user.name }}
+ NuxtLink(:to='"/users/" + user.userId') {{ user.name }}
NButton(
:loading='loadingUserFollow',
:type='user.isFollowed ? "success" : undefined'
@@ -16,11 +16,11 @@
round
secondary
size='small'
- v-if='user.userId !== userStore.userId'
+ v-if='user.userId !== userStore.id'
)
template(#icon)
- IFasCheck(v-if='user.isFollowed')
- IFasPlus(v-else)
+ ICheck(v-if='user.isFollowed')
+ IPlus(v-else)
| {{ user.isFollowed ? '已关注' : '关注' }}
NEllipsis.description.pre(:line-clamp='3', :tooltip='false') {{ user.comment }}
ArtworkList.tiny(:list='user.illusts' inline)
@@ -35,24 +35,17 @@
diff --git a/src/components/NProgress.vue b/app/components/NProgress.vue
similarity index 92%
rename from src/components/NProgress.vue
rename to app/components/NProgress.vue
index 5a9f6bec..9eaab7ab 100644
--- a/src/components/NProgress.vue
+++ b/app/components/NProgress.vue
@@ -1,4 +1,6 @@
-
+
+#nprogress
+
diff --git a/src/components/SideNav/SideNav.vue b/app/components/SideNav/Body.vue
similarity index 60%
rename from src/components/SideNav/SideNav.vue
rename to app/components/SideNav/Body.vue
index 9247a69c..8cfdf48c 100644
--- a/src/components/SideNav/SideNav.vue
+++ b/app/components/SideNav/Body.vue
@@ -10,54 +10,44 @@ aside.global-side-nav(:class='{ hidden: !sideNavStore.isOpened }')
.group
.title 导航
ul
- ListLink(link='/' text='首页')
- IFasHome.link-icon
- ListLink.not-allowed(link='' text='探索发现')
- IFasImage.link-icon
- ListLink(link='/ranking' text='排行榜')
- IFasCrown.link-icon
+ SideNavListLink(link='/' text='首页')
+ IHome.svg--SideNavListLink
+ SideNavListLink.not-allowed(link='' text='插画')
+ IImage.svg--SideNavListLink
+ SideNavListLink(link='' text='用户')
+ IUser.svg--SideNavListLink
+ SideNavListLink(link='/ranking' text='排行榜')
+ ICrown.svg--SideNavListLink
.group
.title 用户
ul
- ListLink(
+ SideNavListLink(
:text='userStore.isLoggedIn ? "查看令牌" : "设置令牌"'
link='/login'
)
- IFasFingerprint.link-icon
- ListLink(
- :link='userStore.isLoggedIn ? `/users/${userStore.userId}` : `/login?back=${$route.fullPath}`'
- text='我的页面'
- )
- IFasUser.link-icon
- ListLink(
- :link='userStore.isLoggedIn ? `/users/${userStore.userId}/following` : `/login?back=${$route.fullPath}`'
- text='我的关注'
- )
- IFasUser.link-icon
- ListLink(link='/following/latest' text='关注用户的作品')
- IFasUser.link-icon
+ IFingerprint.svg--SideNavListLink
.group
.title PixivNow
ul
- ListLink(externalLink='https://www.pixiv.net/' text='Pixiv.net')
- IFasExternalLinkAlt.link-icon
- ListLink(link='/about' text='关于我们')
- IFasHeart.link-icon
+ SideNavListLink(
+ externalLink='https://www.pixiv.net/'
+ text='Pixiv.net'
+ )
+ IExternalLinkAlt.svg--SideNavListLink
+ SideNavListLink(link='/about' text='关于我们')
+ IHeart.svg--SideNavListLink
diff --git a/src/assets/logo.png b/src/assets/logo.png
deleted file mode 100644
index f3d2503f..00000000
Binary files a/src/assets/logo.png and /dev/null differ
diff --git a/src/components/LazyLoad.vue b/src/components/LazyLoad.vue
deleted file mode 100644
index 772fe87a..00000000
--- a/src/components/LazyLoad.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-
-Component(
- :class='{ lazyload: true, isLoading: !loaded && !error, isLoaded: loaded, isError: error }',
- :height='height',
- :is='loaded ? "img" : "svg"',
- :key='src',
- :src='src',
- :width='width'
- ref='imgRef'
- role='img'
-)
-
-
-
-
-
diff --git a/src/components/userData.ts b/src/components/userData.ts
deleted file mode 100644
index 840c1337..00000000
--- a/src/components/userData.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { PixivUser } from '@/types'
-import Cookies from 'js-cookie'
-
-export function existsSessionId(): boolean {
- const sessionId = Cookies.get('PHPSESSID')
- if (sessionId) {
- return true
- } else {
- Cookies.remove('CSRFTOKEN')
- return false
- }
-}
-
-export async function initUser(): Promise {
- try {
- const { data } = await axios.get<{ userData: PixivUser; token: string }>(
- `/api/user`,
- {
- headers: {
- 'Cache-Control': 'no-store',
- },
- }
- )
- if (data.token) {
- console.log('session ID认证成功', data)
- Cookies.set('CSRFTOKEN', data.token, { secure: true, sameSite: 'Strict' })
- const res = data.userData
- return res
- } else {
- Cookies.remove('CSRFTOKEN')
- return Promise.reject('无效的session ID')
- }
- } catch (err) {
- Cookies.remove('CSRFTOKEN')
- return Promise.reject(err)
- }
-}
-
-export function login(token: string): Promise {
- if (!validateSessionId(token)) {
- console.error('访问令牌格式错误')
- return Promise.reject('访问令牌格式错误')
- }
- Cookies.set('PHPSESSID', token, {
- expires: 180,
- path: '/',
- secure: true,
- sameSite: 'Strict',
- })
- return initUser()
-}
-
-export function logout(): void {
- const token = Cookies.get('PHPSESSID')
- if (token && confirm(`您要移除您的令牌吗?\n${token}`)) {
- Cookies.remove('PHPSESSID')
- Cookies.remove('CSRFTOKEN')
- }
-}
-
-export function validateSessionId(token: string): boolean {
- return /^\d{2,10}_[0-9A-Za-z]{32}$/.test(token)
-}
-
-export function exampleSessionId(): string {
- const uid = new Uint32Array(1)
- window.crypto.getRandomValues(uid)
- const secret = (() => {
- const strSet =
- 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
- const final = []
- const indexes = new Uint8Array(32)
- window.crypto.getRandomValues(indexes)
- for (const i of indexes) {
- const charIndex = Math.floor((i * strSet.length) / 256)
- final.push(strSet[charIndex])
- }
- return final.join('')
- })()
- return `${uid[0]}_${secret}`
-}
diff --git a/src/composables/states.ts b/src/composables/states.ts
deleted file mode 100644
index 74e9bb46..00000000
--- a/src/composables/states.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { defineStore } from 'pinia'
-import { PixivUser } from '@/types'
-
-export const useSideNavStore = defineStore('sidenav', () => {
- const openState = ref(false)
- const isOpened = computed(() => openState.value)
- function toggle() {
- openState.value = !openState.value
- }
- function open() {
- openState.value = true
- }
- function close() {
- openState.value = false
- }
- return { openState, isOpened, toggle, open, close }
-})
-
-export const useUserStore = defineStore('user', () => {
- const user = ref(null)
- const isLoggedIn = computed(() => !!user.value)
- const userId = computed(() => user.value?.id)
- const userName = computed(() => user.value?.name)
- const userPixivId = computed(() => user.value?.pixivId)
- const userProfileImg = computed(() => user.value?.profileImg)
- const userProfileImgBig = computed(() => user.value?.profileImgBig)
- function login(data: PixivUser) {
- user.value = data
- }
- function logout() {
- user.value = null
- }
- return {
- user,
- isLoggedIn,
- userId,
- userName,
- userPixivId,
- userProfileImg,
- userProfileImgBig,
- login,
- logout,
- }
-})
diff --git a/src/config.ts b/src/config.ts
deleted file mode 100644
index 4a7a186d..00000000
--- a/src/config.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-// Env
-import { version } from '../package.json'
-export { version }
-
-export const SITE_ENV =
- import.meta.env.MODE === 'development' ||
- version.includes('-') ||
- location.hostname === 'pixiv-next.vercel.app'
- ? 'development'
- : 'production'
-
-// Copyright links
-// Do not modify please
-export const GITHUB_OWNER = 'FreeNowOrg'
-export const GITHUB_REPO = 'PixivNow'
-export const GITHUB_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`
-
-// Site name
-export const PROJECT_NAME = 'PixivNow'
-export const PROJECT_TAGLINE = 'Enjoy Pixiv Now (pixiv.js.org)'
-
-// Image proxy cache seconds
-export const IMAGE_CACHE_SECONDS = 12 * 60 * 60 * 1000
diff --git a/src/env.d.ts b/src/env.d.ts
deleted file mode 100644
index f86d5a0a..00000000
--- a/src/env.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-///
-
-// declare module '*.vue' {
-// import { ComponentOptions } from 'vue'
-// const componentOptions: ComponentOptions
-// export default componentOptions
-// }
diff --git a/src/main.ts b/src/main.ts
deleted file mode 100644
index 3cc4358c..00000000
--- a/src/main.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { createApp } from 'vue'
-import { SITE_ENV } from '@/config'
-import { registerPlugins } from '@/plugins'
-import App from './App.vue'
-import '@/styles/index.sass'
-
-// Create App
-const app = createApp(App)
-
-registerPlugins(app)
-
-// Mount
-app.mount('#app')
-document.body?.setAttribute('data-env', SITE_ENV)
diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts
deleted file mode 100644
index 55e63af6..00000000
--- a/src/plugins/i18n.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { createI18n, I18n } from 'vue-i18n'
-
-export const SUPPORTED_LOCALES = ['zh-Hans']
-
-export function setupI18n(options = { locale: 'zh-Hans' }) {
- const i18n = createI18n({ ...options, legacy: false })
- setI18nLanguage(i18n, options.locale)
- return i18n
-}
-
-export function setI18nLanguage(
- i18n: I18n,
- locale: string
-) {
- i18n.global.locale.value = locale
- document.querySelector('html')?.setAttribute('lang', locale)
-}
-
-export async function loadLocaleMessages(
- i18n: I18n,
- locale: string
-) {
- const messages = await import(
- /* webpackChunkName: "locale-[request]" */ `@/locales/${locale}.json`
- )
- i18n.global.setLocaleMessage(locale, messages.default)
- setI18nLanguage(i18n, locale)
-
- return nextTick()
-}
diff --git a/src/plugins/index.ts b/src/plugins/index.ts
deleted file mode 100644
index 1001a1e1..00000000
--- a/src/plugins/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { router } from './router'
-import { loadLocaleMessages, setupI18n } from './i18n'
-import { createPinia } from 'pinia'
-import { createGtag } from 'vue-gtag'
-import type { App } from 'vue'
-
-export async function registerPlugins(app: App) {
- const i18n = setupI18n()
- const initialLocale = 'zh-Hans'
- app.use(i18n)
- app.use(router)
- app.use(createPinia())
-
- if (import.meta.env.VITE_GOOGLE_ANALYTICS_ID) {
- app.use(
- createGtag({
- tagId: import.meta.env.VITE_GOOGLE_ANALYTICS_ID,
- pageTracker: {
- router,
- },
- })
- )
- }
-
- await loadLocaleMessages(i18n, initialLocale)
-}
diff --git a/src/plugins/router.ts b/src/plugins/router.ts
deleted file mode 100644
index 1570cc7d..00000000
--- a/src/plugins/router.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
-import { createDiscreteApi } from 'naive-ui'
-const { message } = createDiscreteApi(['message'])
-
-const routes: RouteRecordRaw[] = [
- {
- path: '/',
- name: 'home',
- component: () => import('@/view/index.vue'),
- },
- {
- path: '/artworks/:id',
- alias: ['/illust/:id', '/i/:id'],
- name: 'artworks',
- component: () => import('@/view/artworks.vue'),
- },
- {
- path: '/following/latest',
- alias: ['/bookmark_new_illust'],
- name: 'following-latest',
- component: () => import('@/view/following-latest.vue'),
- },
- {
- path: '/users/:id',
- name: 'users',
- alias: ['/u/:id'],
- component: () => import('@/view/users.vue'),
- },
- {
- path: '/users/:id/following',
- name: 'following',
- component: () => import('@/view/following.vue'),
- },
- {
- path: '/search/:keyword',
- name: 'search-index-redirect',
- redirect: (to) => `/search/${to.params.keyword}/1`,
- },
- {
- path: '/search/:keyword/:p',
- name: 'search',
- component: () => import('@/view/search.vue'),
- },
- {
- path: '/ranking',
- name: 'ranking',
- component: () => import('@/view/ranking.vue'),
- },
- {
- path: '/login',
- name: 'user-login',
- component: () => import('@/view/login.vue'),
- },
- {
- path: '/about',
- name: 'about-us',
- component: () => import('@/view/about.vue'),
- },
- {
- path: '/notifications/2024-04-26',
- name: 'notification-2024-04-26',
- component: () => import('@/view/notifications/2024-04-26.vue'),
- },
- {
- path: '/:pathMatch(.*)*',
- name: 'not-found',
- component: () => import('@/view/404.vue'),
- },
-]
-
-if (import.meta.env.DEV) {
- routes.push({
- path: '/_debug',
- name: 'debug',
- children: [
- {
- path: 'zip',
- name: 'debug-zip',
- component: () => import('@/view/_debug/zip.vue'),
- },
- ],
- })
-}
-
-export const router = createRouter({
- history: createWebHistory(),
- routes,
- scrollBehavior(to, from, savedPosition) {
- if (savedPosition) {
- return savedPosition
- } else {
- return {
- top: 0,
- behavior: 'smooth',
- }
- }
- },
-})
-
-router.afterEach(({ name }) => {
- document.body.setAttribute('data-route', name as string)
- // Fix route when modal opened
- document.body.style.overflow = 'visible'
-})
-
-router.onError((error, to, from) => {
- console.log(error, to, from)
- message.error(error)
-})
-
-export default router
diff --git a/src/types/Users.ts b/src/types/Users.ts
deleted file mode 100644
index ca42b952..00000000
--- a/src/types/Users.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { Artwork, ArtworkInfo } from './Artworks'
-
-export enum UserXRestrict {
- SAFE,
- R18,
- R18G,
-}
-export enum UserPrivacyLevel {
- PUBLIC_FOR_ALL,
- PUBLIC_FOR_FRIENDS,
- PRIVATE,
-}
-
-export interface User {
- userId: `${number}`
- name: string
- image: string
- imageBig: string
- premium: boolean
- isFollowed: boolean
- isMypixiv: boolean
- isBlocking: boolean
- background: {
- url: string | null
- color: string | null
- repeat: string | null
- isPrivate: boolean
- } | null
- sketchLiveId: {} | null
- partial: number
- acceptRequest: boolean
- sketchLives: any[]
- following: number
- followedBack: boolean
- comment: string
- commentHtml: string
- webpage: string | null
- social: {
- twitter?: {
- url: string
- }
- facebook?: {
- url: string
- }
- instagram?: {
- url: string
- }
- [key: string]: any
- }
- region: {
- name: string
- privacyLevel: UserPrivacyLevel
- } | null
- birthDay: {
- name: string
- privacyLevel: UserPrivacyLevel
- } | null
- gender: {
- name: string
- privacyLevel: UserPrivacyLevel
- } | null
- job: {
- name: string
- privacyLevel: UserPrivacyLevel
- } | null
- workspace: {
- userWorkspacePc?: string
- userWorkspaceMonitor?: string
- userWorkspaceTool?: string
- userWorkspaceScanner?: string
- userWorkspaceTablet?: string
- userWorkspaceMouse?: string
- userWorkspacePrinter?: string
- userWorkspaceDesktop?: string
- userWorkspaceMusic?: string
- userWorkspaceDesk?: string
- userWorkspaceChair?: string
- userWorkspaceComment?: string
- wsUrl?: string
- wsBigUrl?: string
- }
- official: boolean
- group: null
- illusts: ArtworkInfo[]
- manga: ArtworkInfo[]
- novels: ArtworkInfo[]
-}
-
-export interface PixivUser {
- id: `${number}`
- pixivId: string
- name: string
- profileImg: string
- profileImgBig: string
- premium: boolean
- xRestrict: UserXRestrict
- adult: boolean
- illustCreator: boolean
- novelCreator: boolean
- hideAiWorks: boolean
- readingStatusEnabled: boolean
- illustMaskRules: any[]
- location: string
- isSensitiveViewable: boolean
-}
-
-export interface UserListItem {
- userId: `${number}`
- userName: string
- profileImageUrl: string
- userComment: string
- following: boolean
- followed: boolean
- isBlocking: boolean
- isMypixiv: boolean
- illusts: ArtworkInfo[]
- novels: any[]
- acceptRequest: boolean
-}
diff --git a/src/types/index.ts b/src/types/index.ts
deleted file mode 100644
index e319f702..00000000
--- a/src/types/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './Artworks'
-export * from './Comment'
-export * from './Users'
diff --git a/src/utils/UgoiraPlayer.ts b/src/utils/UgoiraPlayer.ts
deleted file mode 100644
index e7e3952e..00000000
--- a/src/utils/UgoiraPlayer.ts
+++ /dev/null
@@ -1,509 +0,0 @@
-import { Artwork } from '@/types'
-import { unzip } from 'fflate'
-import Gif from 'gif.js'
-import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url'
-import { encode as encodeMp4 } from 'modern-mp4'
-import { ZipDownloader, ZipDownloaderOptions } from './ZipDownloader'
-
-export interface UgoiraPlayerOptions {
- onDownloadProgress?: (
- progress: number,
- frameIndex: number,
- totalFrames: number
- ) => void
- onDownloadComplete?: () => void
- onDownloadError?: (error: Error) => void
- zipDownloaderOptions?: ZipDownloaderOptions
-}
-
-/**
- * UgoiraPlayer
- * @author dragon-fish
- * @license MIT
- */
-export class UgoiraPlayer {
- private _canvas?: HTMLCanvasElement
- private _illust!: Artwork
- private _meta?: UgoiraMeta
- private isPlaying = false
- private curFrame = 0
- private lastFrameTime = 0
- private cachedImages: Map = new Map()
- private objectURLs: Set = new Set() // 跟踪所有创建的 objectURL
- private files: Record = {}
- private zipDownloader?: ZipDownloader
- private downloadProgress = 0
- private isDownloading = false
- private isDownloadComplete = false
- private downloadStartTime = 0
- private frameDownloadTimes: number[] = []
-
- constructor(
- illust: Artwork,
- public options: UgoiraPlayerOptions = {}
- ) {
- this.reset(illust)
- }
- reset(illust: Artwork) {
- this.destroy()
- this._canvas = undefined
- this._illust = illust
- this.downloadProgress = 0
- this.isDownloading = false
- this.isDownloadComplete = false
- this.downloadStartTime = 0
- this.frameDownloadTimes = []
- }
- setupCanvas(canvas: HTMLCanvasElement) {
- this._canvas = canvas
- this._canvas.width = this.initWidth
- this._canvas.height = this.initHeight
- }
-
- get isReady() {
- return !!this._meta && !!Object.keys(this.files).length
- }
- get canExport() {
- return this.isDownloadComplete && this.isReady
- }
- get downloadProgressPercent() {
- return this.downloadProgress
- }
- get downloadStats() {
- if (!this.isDownloadComplete && this.frameDownloadTimes.length === 0) {
- return null
- }
-
- const totalTime = performance.now() - this.downloadStartTime
- const avgFrameTime =
- this.frameDownloadTimes.length > 0
- ? this.frameDownloadTimes.reduce((a, b) => a + b, 0) /
- this.frameDownloadTimes.length
- : 0
-
- return {
- totalDownloadTime: totalTime,
- averageFrameTime: avgFrameTime,
- totalFrames: this.frameDownloadTimes.length,
- isComplete: this.isDownloadComplete,
- progress: this.downloadProgress,
- }
- }
- get isUgoira() {
- return this._illust.illustType === 2
- }
- get canvas() {
- return this._canvas
- }
- get illust() {
- return this._illust
- }
- get meta() {
- return this._meta
- }
- get totalFrames() {
- return this._meta?.frames.length ?? 0
- }
- get now() {
- return performance.now()
- }
- get initWidth() {
- return this._illust.width
- }
- get initHeight() {
- return this._illust.height
- }
- get mimeType() {
- return this._meta?.mime_type ?? ''
- }
-
- async fetchMeta() {
- this._meta = await fetch(
- new URL(`/ajax/illust/${this._illust.id}/ugoira_meta`, location.href)
- .href,
- {
- cache: 'default',
- }
- ).then((res) => res.json())
- return this
- }
-
- async fetchFrames(originalQuality = false) {
- if (!this._meta) {
- await this.fetchMeta()
- }
- if (!this._meta) {
- throw new Error('Failed to fetch meta')
- }
-
- // 使用 ZipDownloader 进行优化下载
- return this.fetchFramesOptimized(originalQuality)
- }
-
- /**
- * 使用 ZipDownloader 优化的帧下载方法
- * 1. 首先拉取zip的元信息,确定文件分片情况
- * 2. 根据UgoiraMeta的frame信息,依次下载文件
- * 3. 下载完一张图片时:如果下载用时大于前一帧的delay,立即渲染到canvas,否则等待delay后渲染到canvas
- * 4. 全部帧下载完毕,开始按照frame的定义正常循环播放动画,标记为可以导出为gif或mp4
- */
- private async fetchFramesOptimized(originalQuality = false) {
- if (this.isDownloading) {
- throw new Error('Download already in progress')
- }
-
- this.isDownloading = true
- this.downloadStartTime = performance.now()
- this.downloadProgress = 0
- this.frameDownloadTimes = []
-
- try {
- const zipUrl = new URL(
- this._meta![originalQuality ? 'originalSrc' : 'src'],
- location.href
- ).href
-
- // 1. 创建 ZipDownloader 并获取元信息
- this.zipDownloader = new ZipDownloader(zipUrl, {
- chunkSize: 128 * 1024,
- maxConcurrentRequests: 3,
- tryDecompress: true,
- timeoutMs: 10000,
- retries: 2,
- ...this.options.zipDownloaderOptions,
- })
-
- console.log('[UgoiraPlayer] 开始获取 ZIP 元信息...')
- const overview = await this.zipDownloader.getCentralDirectory()
- console.log('[UgoiraPlayer] ZIP 元信息获取完成:', {
- entryCount: overview.entryCount,
- entries: overview.entries.map((e) => e.fileName),
- })
-
- // 2. 根据 UgoiraMeta 的 frame 信息,依次下载文件
- const { frames } = this._meta!
- const totalFrames = frames.length
-
- for (let i = 0; i < totalFrames; i++) {
- const frame = frames[i]
- const frameStartTime = performance.now()
-
- try {
- // 下载单个帧文件
- const result = await this.zipDownloader.downloadByPath(frame.file)
- this.files[frame.file] = result.bytes
-
- const frameDownloadTime = performance.now() - frameStartTime
- this.frameDownloadTimes[i] = frameDownloadTime
-
- // 更新下载进度
- this.downloadProgress = ((i + 1) / totalFrames) * 100
-
- console.log(`[UgoiraPlayer] 帧 ${i + 1}/${totalFrames} 下载完成:`, {
- fileName: frame.file,
- downloadTime: frameDownloadTime,
- delay: frame.delay,
- progress: this.downloadProgress.toFixed(1) + '%',
- })
-
- // 触发进度回调
- this.options.onDownloadProgress?.(
- this.downloadProgress,
- i,
- totalFrames
- )
-
- // 3. 智能渲染逻辑:根据下载时间与delay比较决定立即渲染或等待
- await this.handleFrameRender(i, frame, frameDownloadTime)
- } catch (error) {
- console.error(`[UgoiraPlayer] 帧 ${i + 1} 下载失败:`, error)
- throw new Error(`Failed to download frame ${i + 1}: ${frame.file}`, {
- cause: error,
- })
- }
- }
-
- // 4. 全部帧下载完毕,标记为可以导出
- this.isDownloadComplete = true
- this.isDownloading = false
-
- const totalDownloadTime = performance.now() - this.downloadStartTime
- console.log('[UgoiraPlayer] 所有帧下载完成:', {
- totalFrames,
- totalDownloadTime: totalDownloadTime.toFixed(2) + 'ms',
- averageFrameTime: (totalDownloadTime / totalFrames).toFixed(2) + 'ms',
- canExport: this.canExport,
- })
-
- // 触发完成回调
- this.options.onDownloadComplete?.()
-
- return this
- } catch (error) {
- this.isDownloading = false
- console.error('[UgoiraPlayer] 下载过程出错:', error)
-
- // 触发错误回调
- this.options.onDownloadError?.(error as Error)
-
- throw error
- }
- }
-
- /**
- * 处理帧渲染逻辑
- * 如果下载用时大于前一帧的delay,立即渲染到canvas,否则等待delay后渲染到canvas
- */
- private async handleFrameRender(
- frameIndex: number,
- frame: UgoiraFrame,
- downloadTime: number
- ) {
- if (!this._canvas) {
- return // 没有canvas,跳过渲染
- }
-
- const ctx = this._canvas.getContext('2d')
- if (!ctx) {
- return
- }
-
- // 获取前一帧的delay(第一帧没有前一帧,使用当前帧的delay)
- const previousDelay =
- frameIndex > 0 ? this._meta!.frames[frameIndex - 1].delay : frame.delay
-
- // 如果下载时间大于前一帧的delay,立即渲染
- if (downloadTime > previousDelay) {
- console.log(
- `[UgoiraPlayer] 帧 ${frameIndex + 1} 下载时间(${downloadTime.toFixed(2)}ms) > 前一帧delay(${previousDelay}ms),立即渲染`
- )
- await this.renderFrameToCanvas(frameIndex, frame)
- } else {
- // 否则等待delay后渲染
- const waitTime = previousDelay - downloadTime
- console.log(
- `[UgoiraPlayer] 帧 ${frameIndex + 1} 下载时间(${downloadTime.toFixed(2)}ms) <= 前一帧delay(${previousDelay}ms),等待${waitTime.toFixed(2)}ms后渲染`
- )
-
- await new Promise((resolve) => setTimeout(resolve, waitTime))
- await this.renderFrameToCanvas(frameIndex, frame)
- }
- }
-
- /**
- * 将指定帧渲染到canvas
- */
- private async renderFrameToCanvas(frameIndex: number, frame: UgoiraFrame) {
- if (!this._canvas) return
-
- const ctx = this._canvas.getContext('2d')
- if (!ctx) return
-
- try {
- const img = await this.getImageAsync(frame.file)
- ctx.drawImage(img, 0, 0, this.initWidth, this.initHeight)
- console.log(`[UgoiraPlayer] 帧 ${frameIndex + 1} 已渲染到canvas`)
- } catch (error) {
- console.error(`[UgoiraPlayer] 帧 ${frameIndex + 1} 渲染失败:`, error)
- }
- }
-
- private getImage(fileName: string): HTMLImageElement {
- if (this.cachedImages.has(fileName)) {
- return this.cachedImages.get(fileName)!
- }
- const buf = this.files[fileName]
- if (!buf) {
- throw new Error(`File ${fileName} not found`)
- }
- const img = new Image()
- const objectURL = URL.createObjectURL(
- new Blob([buf], { type: this.mimeType })
- )
- this.objectURLs.add(objectURL)
- img.src = objectURL
- this.cachedImages.set(fileName, img)
- return img
- }
-
- private async getImageAsync(fileName: string): Promise {
- if (this.cachedImages.has(fileName)) {
- const img = this.cachedImages.get(fileName)!
- // 如果图片已经加载完成,直接返回
- if (img.complete && img.naturalWidth > 0) {
- return img
- }
- // 否则等待加载完成
- return new Promise((resolve, reject) => {
- img.onload = () => resolve(img)
- img.onerror = reject
- })
- }
-
- const buf = this.files[fileName]
- if (!buf) {
- throw new Error(`File ${fileName} not found`)
- }
-
- const img = new Image()
- const objectURL = URL.createObjectURL(
- new Blob([buf], { type: this.mimeType })
- )
- this.objectURLs.add(objectURL)
- img.src = objectURL
- this.cachedImages.set(fileName, img)
-
- return new Promise((resolve, reject) => {
- img.onload = () => resolve(img)
- img.onerror = reject
- })
- }
-
- getRealFrameSize() {
- if (!this.isReady) {
- throw new Error('Ugoira assets not ready, please fetch first')
- }
- const firstFrame = this.getImage(this.meta!.frames[0].file)
- return {
- width: firstFrame.width,
- height: firstFrame.height,
- }
- }
-
- private drawFrame() {
- if (!this.canvas || !this._meta || !this.isPlaying) {
- return
- }
- const ctx = this.canvas.getContext('2d')!
- const frame = this._meta.frames[this.curFrame]
- const delay = frame.delay
- const now = this.now
- const delta = now - this.lastFrameTime
- if (delta > delay) {
- this.lastFrameTime = now
- this.curFrame = (this.curFrame + 1) % this.totalFrames
- }
- const img = this.getImage(frame.file)
- ctx.drawImage(img, 0, 0, this.initWidth, this.initHeight)
- requestAnimationFrame(() => this.drawFrame())
- }
-
- play() {
- this.isPlaying = true
- this.lastFrameTime = this.now
- this.drawFrame()
- }
-
- pause() {
- this.isPlaying = false
- }
-
- destroy() {
- this.pause()
-
- // 清理所有 objectURL 防止内存泄漏
- this.objectURLs.forEach((url) => {
- URL.revokeObjectURL(url)
- })
- this.objectURLs.clear()
-
- this.cachedImages.clear()
- this.files = {}
- this._meta = undefined
- this.zipDownloader = undefined
- this.isDownloading = false
- this.isDownloadComplete = false
- this.downloadProgress = 0
- this.downloadStartTime = 0
- this.frameDownloadTimes = []
- }
-
- private genGifEncoder() {
- const { width, height } = this.getRealFrameSize()
- return new Gif({
- debug: import.meta.env.DEV,
- workers: 5,
- workerScript: gifWorkerUrl,
- width,
- height,
- })
- }
- async renderGif(): Promise {
- if (!this.canExport) {
- throw new Error(
- 'Cannot export: download not complete or assets not ready'
- )
- }
-
- const encoder = this.genGifEncoder()
- const frames = this._meta!.frames
- const imageList = await Promise.all(
- frames.map((i) => {
- return this.getImage(i.file)
- })
- )
- return new Promise((resolve, reject) => {
- imageList.forEach((item, index) => {
- encoder.addFrame(item, { delay: frames[index].delay })
- })
- encoder.on('finished', async (blob) => {
- console.info('[ENCODER]', 'render finished', encoder)
- // FIXME: 渲染结束时释放内存
- // @ts-ignore
- encoder.freeWorkers?.forEach((worker: Worker) => {
- worker && worker.terminate()
- })
- resolve(blob)
- })
- console.info('[ENCODER]', 'render start', encoder)
- encoder.render()
- })
- }
-
- async renderMp4() {
- if (!this.canExport) {
- throw new Error(
- 'Cannot export: download not complete or assets not ready'
- )
- }
-
- const { width, height } = this.getRealFrameSize()
- const frames = this._meta!.frames.map((i) => {
- return {
- data: this.getImage(i.file).src!,
- duration: i.delay,
- }
- })
- console.info({ width, height, frames })
- const buf = await encodeMp4({
- frames,
- width,
- height,
- audio: false,
- })
- const blob = new Blob([buf], { type: 'video/mp4' })
- return blob
- }
-
- async unzipAsync(payload: Uint8Array) {
- return new Promise>((resolve, reject) => {
- unzip(payload, (err, data) => {
- if (err) {
- return reject(err)
- }
- resolve(data)
- })
- })
- }
-}
-
-export interface UgoiraFrame {
- file: string
- delay: number
-}
-export interface UgoiraMeta {
- frames: UgoiraFrame[]
- mime_type: string
- originalSrc: string
- src: string
-}
diff --git a/src/utils/ZipDownloader.ts b/src/utils/ZipDownloader.ts
deleted file mode 100644
index c36e9a7f..00000000
--- a/src/utils/ZipDownloader.ts
+++ /dev/null
@@ -1,983 +0,0 @@
-/**
- * ZipDownloader - 支持分片下载和缓存的 ZIP 文件下载器
- *
- * 使用示例:
- *
- * // 使用默认配置(128KB 分片,32KB 初始尾部抓取)
- * const downloader = new ZipDownloader('https://example.com/file.zip')
- *
- * // 自定义分片大小和尾部抓取策略
- * const downloader = new ZipDownloader('https://example.com/file.zip', {
- * chunkSize: 256 * 1024, // 256KB 分片
- * initialTailSize: 16 * 1024, // 16KB 初始尾部抓取
- * maxTailSize: 128 * 1024, // 128KB 最大尾部抓取
- * timeoutMs: 10000, // 10秒超时
- * retries: 3, // 重试3次
- * lruBytes: 32 * 1024 * 1024, // 32MB 缓存
- * parallelProbe: 4, // 4个并发探测
- * maxConcurrentRequests: 6, // 最大6个并发请求
- * tryDecompress: true // 尝试解压
- * })
- *
- * // 获取文件列表
- * const overview = await downloader.getCentralDirectory()
- *
- * // 下载单个文件
- * const result = await downloader.downloadByIndex(0)
- * const fileData = result.bytes
- */
-
-type FetchLike = (
- input: RequestInfo | URL,
- init?: RequestInit
-) => Promise
-
-export type ZipDownloaderOptions = {
- fetch?: FetchLike // 替换 fetch(Node 可注入 node-fetch/undici)
- timeoutMs?: number // 单请求超时
- retries?: number // 失败重试次数(指数退避)
- lruBytes?: number // Range 缓存的最大总字节数(近似)
- parallelProbe?: number // 批量探测本地头的并发度
- maxConcurrentRequests?: number // 最大并发请求数,默认 6
- tryDecompress?: boolean // 尝试解压(method 0/8)
- chunkSize?: number // 分片大小(字节),默认 128KB
- initialTailSize?: number // 初始尾部抓取大小(字节),默认 32KB
- maxTailSize?: number // 最大尾部抓取大小(字节),默认 70KB
-}
-
-export type ZipEntry = {
- index: number
- fileName: string
- compressedSize: number
- uncompressedSize: number
- crc32: number
- compressionMethod: number // 0=store, 8=deflate
- generalPurposeBitFlag: number
- localHeaderOffset: number
- centralHeaderOffset: number
- requiresZip64: boolean
- mimeType?: string // 通过扩展名推断的MIME类型
-}
-
-export type ZipOverview = {
- url: string
- contentLength: number
- centralDirectoryOffset: number
- centralDirectorySize: number
- entryCount: number
- entries: ZipEntry[]
-}
-
-export type DataRange = {
- index: number
- fileName: string
- dataStart: number // 压缩数据起点(不含本地头/文件名/extra)
- dataLength: number // 压缩数据长度
-}
-
-const SIG = {
- EOCD: 0x06054b50,
- ZIP64_EOCD_LOCATOR: 0x07064b50,
- ZIP64_EOCD: 0x06064b50,
- CD_FILE_HEADER: 0x02014b50,
- LOCAL_FILE_HEADER: 0x04034b50,
-}
-
-const TEXT_DECODER = new TextDecoder()
-
-// MIME类型映射表
-const MIME_TYPES: Record = {
- // 图片格式
- png: 'image/png',
- jpg: 'image/jpeg',
- jpeg: 'image/jpeg',
- gif: 'image/gif',
- webp: 'image/webp',
- bmp: 'image/bmp',
- svg: 'image/svg+xml',
- ico: 'image/x-icon',
- tiff: 'image/tiff',
- tif: 'image/tiff',
-
- // 视频格式
- mp4: 'video/mp4',
- webm: 'video/webm',
- avi: 'video/x-msvideo',
- mov: 'video/quicktime',
- wmv: 'video/x-ms-wmv',
- flv: 'video/x-flv',
- mkv: 'video/x-matroska',
-
- // 音频格式
- mp3: 'audio/mpeg',
- wav: 'audio/wav',
- ogg: 'audio/ogg',
- aac: 'audio/aac',
- flac: 'audio/flac',
- m4a: 'audio/mp4',
-
- // 文档格式
- pdf: 'application/pdf',
- doc: 'application/msword',
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- xls: 'application/vnd.ms-excel',
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- ppt: 'application/vnd.ms-powerpoint',
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
-
- // 文本格式
- txt: 'text/plain',
- html: 'text/html',
- htm: 'text/html',
- css: 'text/css',
- js: 'application/javascript',
- json: 'application/json',
- xml: 'application/xml',
- csv: 'text/csv',
-
- // 压缩格式
- zip: 'application/zip',
- rar: 'application/vnd.rar',
- '7z': 'application/x-7z-compressed',
- tar: 'application/x-tar',
- gz: 'application/gzip',
-
- // 其他
- exe: 'application/x-msdownload',
- dmg: 'application/x-apple-diskimage',
- iso: 'application/x-iso9660-image',
-}
-
-/**
- * 通过文件扩展名获取MIME类型
- */
-function getMimeTypeFromExtension(fileName: string): string {
- const extension = fileName.split('.').pop()?.toLowerCase()
- if (!extension) return 'application/octet-stream'
- return MIME_TYPES[extension] || 'application/octet-stream'
-}
-
-/**
- * 通过文件头(Magic Number)检测MIME类型
- */
-function getMimeTypeFromMagicNumber(data: Uint8Array): string {
- if (data.length < 4) return 'application/octet-stream'
-
- // 检查文件头签名
- const header = Array.from(data.slice(0, 16))
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('')
-
- // PNG: 89 50 4E 47 0D 0A 1A 0A
- if (header.startsWith('89504e470d0a1a0a')) return 'image/png'
-
- // JPEG: FF D8 FF
- if (header.startsWith('ffd8ff')) return 'image/jpeg'
-
- // GIF: 47 49 46 38 (GIF8)
- if (header.startsWith('47494638')) return 'image/gif'
-
- // WebP: 52 49 46 46 ... 57 45 42 50
- if (header.startsWith('52494646') && data.length >= 12) {
- const webpHeader = Array.from(data.slice(8, 12))
- .map((b) => String.fromCharCode(b))
- .join('')
- if (webpHeader === 'WEBP') return 'image/webp'
- }
-
- // BMP: 42 4D
- if (header.startsWith('424d')) return 'image/bmp'
-
- // SVG: 检查是否以