Compare commits

...

24 Commits

Author SHA1 Message Date
HouYunFei 8aa3fad684 chore: update Docker image names to reflect new repository ownership 2026-06-08 15:30:08 +08:00
HouYunFei 22f256d1be chore: update changelog for v0.2.5 release and bump version to v0.2.5 2026-06-08 15:27:33 +08:00
HouYunFei 8d0b5ecdb8 feat(video): implement video generation task creation and polling mechanism 2026-06-08 15:26:35 +08:00
HouYunFei 373229a04a fix(canvas): resolve text node editing issues and improve textarea rendering 2026-06-08 15:11:16 +08:00
HouYunFei 39a1e859b1 feat(webdav): add WebDAV synchronization configuration and functionality 2026-06-08 14:59:30 +08:00
kunkun dd9347e85d Merge pull request #47 from YuSaZh/main
ci: 增加 ARM 架构 Docker 镜像自动构建
2026-06-08 11:30:16 +08:00
YuSaZh 13418a4d26 ci: build docker images on native runners 2026-06-07 22:25:30 +09:00
YuSaZh 506378c308 ci: publish multi-arch docker images 2026-06-07 21:46:01 +09:00
HouYunFei 81b9a207b9 feat(toolbar): add image splitting feature to toolbar for generating sub-images 2026-06-07 12:25:52 +08:00
kunkun 3667557dc2 Merge pull request #37 from 1847123733/main
添加图像节点双击放大预览功能
2026-06-04 17:41:54 +08:00
HouYunFei b05d759f20 chore: bump version from v0.2.3 to v0.2.4 2026-06-04 17:25:39 +08:00
HouYunFei 6d3b89c842 feat(toolbar): add reverse prompt feature to image node toolbar for generating prompt text 2026-06-04 17:25:30 +08:00
1847123733 eb050fc08a Merge branch 'basketikun:main' into main 2026-06-04 17:15:17 +08:00
王鑫凯 72024c8138 feat(canvas): 添加图像节点预览功能
- 在CanvasNode组件中添加onViewImage回调属性
- 实现图像节点点击时触发预览功能
- 修复事件冒泡问题,确保图像预览操作不会触发其他交互
2026-06-04 17:13:28 +08:00
HouYunFei 51df099936 chore: bump version from v0.2.2 to v0.2.3 2026-06-04 17:03:54 +08:00
HouYunFei 96cca4d97c feat(canvas): enhance configuration node with new input handling and reference selection features 2026-06-04 17:03:48 +08:00
kunkun 75d5af5d8c Bump version from v0.2.1 to v0.2.2 2026-06-04 16:30:40 +08:00
HouYunFei 8a51cc609f feat(toolbar): add mask editing feature to image node toolbar for localized modifications 2026-06-04 16:12:38 +08:00
HouYunFei 0adf547db9 feat(toolbar): update image toolbar tools visibility settings for better user experience 2026-06-04 15:19:05 +08:00
HouYunFei 163dc51203 feat(database): add automatic database creation for MySQL and PostgreSQL 2026-06-04 14:22:20 +08:00
HouYunFei 68aca6fa16 feat(toolbar): enhance image node hover toolbar with new tools and settings modal 2026-06-03 17:52:01 +08:00
HouYunFei ceb7605a1e feat(canvas): add image upscaling and super resolution features with UI enhancements 2026-06-03 16:05:35 +08:00
stupid-h4er 76a4e7c4a2 fix: prefer config resources for canvas mentions 2026-06-03 12:49:29 +08:00
stupid-h4er c70e7af14d feat: add canvas resource mention references 2026-06-03 11:26:59 +08:00
35 changed files with 3565 additions and 486 deletions
+5 -2
View File
@@ -6,8 +6,9 @@ ADMIN_PASSWORD=infinite-canvas
JWT_SECRET=infinite-canvas
JWT_EXPIRE_HOURS=168
# 后端监听端口
PORT=8080
# 后端默认监听 8080,如需本地开发修改端口再取消注释。
# Docker 镜像内前端固定监听 3000,避免覆盖前端 PORT。
# PORT=8080
# 公开访问地址,用于把本地上传的 Seedance 参考图/视频暴露给火山方舟拉取。
# 线上部署时填写站点根地址,例如:https://your-domain.example.com
@@ -21,6 +22,8 @@ NEXT_PUBLIC_DOC_URL=https://docs.canvas.best
STORAGE_DRIVER=sqlite
# sqlite: DATABASE_DSN=data/infinite-canvas.db
# Docker 部署时建议使用绝对路径,避免工作目录变化后写入临时库:DATABASE_DSN=/app/data/infinite-canvas.db
# mysql 目标库不存在时会尝试自动创建,账号需有 CREATE 权限。
# mysql: DATABASE_DSN=user:password@tcp(127.0.0.1:3306)/infinite_canvas?parseTime=true
# postgres 目标库不存在时会尝试自动创建,账号需有 CREATEDB 权限。
# postgres: DATABASE_DSN=postgres://user:password@127.0.0.1:5432/infinite_canvas?sslmode=disable
DATABASE_DSN=data/infinite-canvas.db
+89 -15
View File
@@ -9,9 +9,37 @@ permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/basketikun/infinite-canvas
jobs:
build:
meta:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
steps:
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=sha,prefix=
build:
needs: meta
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
@@ -23,19 +51,65 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=sha,prefix=
- uses: docker/build-push-action@v6
- id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ matrix.platform }}
labels: ${{ needs.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=app-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=app-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/digests"
digest="${{ steps.build.outputs.digest }}"
touch "$RUNNER_TEMP/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs:
- meta
- build
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Create multi-arch manifest
shell: bash
env:
TAGS: ${{ needs.meta.outputs.tags }}
run: |
tag_args=()
while IFS= read -r tag; do
if [ -n "$tag" ]; then
tag_args+=("-t" "$tag")
fi
done <<< "$TAGS"
digest_args=()
while IFS= read -r digest_file; do
digest_args+=("${IMAGE_NAME}@sha256:${digest_file}")
done < <(find "$RUNNER_TEMP/digests" -type f -printf '%f\n' | sort)
docker buildx imagetools create "${tag_args[@]}" "${digest_args[@]}"
+90 -16
View File
@@ -9,9 +9,38 @@ permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/basketikun/infinite-canvas-docs
jobs:
build:
meta:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
steps:
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=ref,event=tag
type=sha,prefix=
build:
needs: meta
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
@@ -23,21 +52,66 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}-docs
tags: |
type=raw,value=latest
type=ref,event=tag
type=sha,prefix=
- uses: docker/build-push-action@v6
- id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./docs/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=docs
cache-to: type=gha,mode=max,scope=docs
platforms: ${{ matrix.platform }}
labels: ${{ needs.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docs-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docs-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/digests"
digest="${{ steps.build.outputs.digest }}"
touch "$RUNNER_TEMP/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v4
with:
name: docs-digests-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs:
- meta
- build
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: docs-digests-*
merge-multiple: true
- name: Create multi-arch manifest
shell: bash
env:
TAGS: ${{ needs.meta.outputs.tags }}
run: |
tag_args=()
while IFS= read -r tag; do
if [ -n "$tag" ]; then
tag_args+=("-t" "$tag")
fi
done <<< "$TAGS"
digest_args=()
while IFS= read -r digest_file; do
digest_args+=("${IMAGE_NAME}@sha256:${digest_file}")
done < <(find "$RUNNER_TEMP/digests" -type f -printf '%f\n' | sort)
docker buildx imagetools create "${tag_args[@]}" "${digest_args[@]}"
+21
View File
@@ -2,6 +2,27 @@
## Unreleased
## v0.2.5 - 2026-06-08
+ [新增] 新增图片切图功能。
+ [新增] 支持webdav同步数据。
+ [修复] 修复画布文字节点错误问题。
## v0.2.4 - 2026-06-04
+ [新增] 新增图片反推提示词功能。
## v0.2.3 - 2026-06-04
+ [新增] 新增图片蒙版局部修改功能。
+ [优化] 优化配置节点@图片功能
## v0.2.2 - 2026-06-04
+ [新增] 新增图片放大工具。
+ [优化] 优化图片工具条,增加自定义功能。
+ [修复] 修复端口冲突问题、pg/mysql未初始化问题。
## v0.2.1 - 2026-06-03
+ [新增] 新增文档站点页面。
+1 -1
View File
@@ -43,4 +43,4 @@ RUN mkdir -p /app/data/prompts
EXPOSE 3000
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && node server.js"]
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && PORT=3000 node server.js"]
+1 -1
View File
@@ -1 +1 @@
v0.2.1
v0.2.5
@@ -5,6 +5,16 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 视频创作台创建视频任务成功后会立即写入左侧生成记录,状态显示为“生成中”;刷新页面后会读取本地任务 ID 继续轮询,任务成功或失败后更新同一条记录,需要验证 OpenAI 视频接口和 Seedance 任务接口的刷新恢复。
- 修复画布文字节点编辑时双击进入编辑、全选文本出现重影和错位的问题;文字节点内容编辑框改为使用原生 textarea 文本渲染,仍保留 `@` 资源候选插入,需要验证编辑态选区、展示态文字换行和右上角“生图”按钮避让都正常。
- 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。
- 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。
- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。
- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。
- 图片节点悬浮工具栏新增“反推提示词”入口;点击后会在原图右侧创建包含反推预设提示词的文本节点,并继续创建一个已切到“文本”模式的生成配置节点,图片节点和文本节点都会自动连到该配置节点,配置节点会预填包含原图和文本节点引用的组装提示词并自动打开,用户可自行点击配置节点开始生成。
- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。
- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框和节点底部 prompt 面板输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。
- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。
- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。
- 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。
- 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。
+2 -2
View File
@@ -7,6 +7,8 @@ require (
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/go-sql-driver/mysql v1.8.1
github.com/jackc/pgx/v5 v5.6.0
github.com/joho/godotenv v1.5.1
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.48.0
@@ -27,13 +29,11 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.3.0
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+113 -2
View File
@@ -1,6 +1,9 @@
package repository
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
@@ -9,7 +12,10 @@ import (
"github.com/basketikun/infinite-canvas/config"
"github.com/basketikun/infinite-canvas/model"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
mysqldriver "github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
gormmysql "gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
@@ -41,6 +47,18 @@ func DB() (*gorm.DB, error) {
if driver == "sqlite" && dsn != ":memory:" {
_ = os.MkdirAll(filepath.Dir(dsn), 0755)
}
if isPostgresDriver(driver) {
dbErr = ensurePostgresDatabase(dsn)
if dbErr != nil {
return
}
}
if driver == "mysql" {
dbErr = ensureMySQLDatabase(dsn)
if dbErr != nil {
return
}
}
db, dbErr = gorm.Open(dialector(driver, dsn), &gorm.Config{})
if dbErr != nil {
return
@@ -59,10 +77,103 @@ func DB() (*gorm.DB, error) {
func dialector(driver string, dsn string) gorm.Dialector {
switch driver {
case "mysql":
return mysql.Open(dsn)
return gormmysql.Open(dsn)
case "postgres", "postgresql":
return postgres.Open(dsn)
default:
return sqlite.Open(dsn)
}
}
func isPostgresDriver(driver string) bool {
return driver == "postgres" || driver == "postgresql"
}
func ensureMySQLDatabase(dsn string) error {
cfg, err := mysqldriver.ParseDSN(dsn)
if err != nil {
return err
}
target := strings.TrimSpace(cfg.DBName)
if target == "" {
return nil
}
ctx := context.Background()
targetDB, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
err = targetDB.PingContext(ctx)
_ = targetDB.Close()
if err == nil {
return nil
}
if !isMySQLError(err, 1049) {
return err
}
maintenance := cfg.Clone()
maintenance.DBName = ""
serverDB, err := sql.Open("mysql", maintenance.FormatDSN())
if err != nil {
return err
}
defer serverDB.Close()
_, err = serverDB.ExecContext(ctx, "CREATE DATABASE "+quoteMySQLIdentifier(target)+" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
if isMySQLError(err, 1007) {
return nil
}
return err
}
func ensurePostgresDatabase(dsn string) error {
cfg, err := pgx.ParseConfig(dsn)
if err != nil {
return err
}
target := strings.TrimSpace(cfg.Database)
if target == "" {
return nil
}
ctx := context.Background()
conn, err := pgx.ConnectConfig(ctx, cfg)
if err == nil {
_ = conn.Close(ctx)
return nil
}
if !isPostgresError(err, "3D000") {
return err
}
maintenance := cfg.Copy()
maintenance.Database = "postgres"
if strings.EqualFold(target, "postgres") {
maintenance.Database = "template1"
}
conn, err = pgx.ConnectConfig(ctx, maintenance)
if err != nil {
return err
}
defer conn.Close(ctx)
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{target}.Sanitize(), pgx.QueryExecModeExec)
if isPostgresError(err, "42P04") {
return nil
}
return err
}
func isMySQLError(err error, number uint16) bool {
var mysqlErr *mysqldriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == number
}
func isPostgresError(err error, code string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == code
}
func quoteMySQLIdentifier(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
@@ -19,16 +19,20 @@ import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { useAssetStore } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { cropDataUrl } from "../utils/canvas-image-data";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "../utils/canvas-image-data";
import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
import { App, Button, Dropdown, Modal } from "antd";
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
import { CanvasConfigComposer } from "../components/canvas-config-composer";
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog";
import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "../components/canvas-node-mask-edit-dialog";
import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "../components/canvas-node-split-dialog";
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "../components/canvas-node-upscale-dialog";
import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation";
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar";
import { InfiniteCanvas } from "../components/infinite-canvas";
@@ -39,6 +43,7 @@ import { CanvasToolbar } from "../components/canvas-toolbar";
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
import { useCanvasStore } from "../stores/use-canvas-store";
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
import {
CanvasNodeType,
type CanvasAssistantImage,
@@ -85,6 +90,12 @@ const CONNECTION_NODE_HIT_PADDING = 32;
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
要求:
1. 只输出提示词正文,不要解释。
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
3. 尽量写成可直接用于生图模型的完整提示词。`;
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(type);
@@ -271,6 +282,10 @@ function InfiniteCanvasPage() {
const [editRequestNonce, setEditRequestNonce] = useState(0);
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
@@ -525,7 +540,8 @@ function InfiniteCanvasPage() {
const padding = CONNECTION_NODE_HIT_PADDING / scale;
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
let isNearNode = false;
let best: { nodeId: string; priority: number } | null = null;
let bestNodeId: string | null = null;
let bestPriority = Number.POSITIVE_INFINITY;
[...nodesRef.current]
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
@@ -543,10 +559,13 @@ function InfiniteCanvasPage() {
if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return;
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
if (!best || priority < best.priority) best = { nodeId: node.id, priority };
if (priority < bestPriority) {
bestNodeId = node.id;
bestPriority = priority;
}
});
return { nodeId: best?.nodeId || null, isNearNode };
return { nodeId: bestNodeId, isNearNode };
},
[screenToCanvas],
);
@@ -568,6 +587,10 @@ function InfiniteCanvasPage() {
const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
const splitNode = splitNodeId ? nodeById.get(splitNodeId) || null : null;
const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null;
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
const hasMultipleSelectedNodes = selectedNodeIds.size > 1;
@@ -617,7 +640,14 @@ function InfiniteCanvasPage() {
});
return map;
}, [connections, nodes]);
const resourceContextNodeId = dialogNodeId || activeNodeId;
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(nodes, connections, resourceContextNodeId), [connections, nodes, resourceContextNodeId]);
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
const mentionReferencesByNodeId = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections)));
return map;
}, [connections, nodes]);
const createNode = useCallback(
(type: CanvasNodeType, position?: Position) => {
const targetPosition = position || getCanvasCenter();
@@ -675,6 +705,7 @@ function InfiniteCanvasPage() {
setEditingNodeId((current) => (current && allIds.has(current) ? null : current));
setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
setCropNodeId((current) => (current && allIds.has(current) ? null : current));
setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current));
setAngleNodeId((current) => (current && allIds.has(current) ? null : current));
setPreviewNodeId((current) => (current && allIds.has(current) ? null : current));
setRunningNodeId((current) => (current && allIds.has(current) ? null : current));
@@ -707,6 +738,7 @@ function InfiniteCanvasPage() {
setConnections([]);
setInfoNodeId(null);
setCropNodeId(null);
setMaskEditNodeId(null);
setAngleNodeId(null);
setPreviewNodeId(null);
setRunningNodeId(null);
@@ -1203,7 +1235,8 @@ function InfiniteCanvasPage() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return;
const target = event.target instanceof Element ? event.target : null;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return;
const key = event.key.toLowerCase();
const isModifierShortcut = event.metaKey || event.ctrlKey;
@@ -1262,6 +1295,7 @@ function InfiniteCanvasPage() {
setEditingNodeId(null);
setInfoNodeId(null);
setCropNodeId(null);
setMaskEditNodeId(null);
setPendingConnectionCreate(null);
}
};
@@ -1416,6 +1450,53 @@ function InfiniteCanvasPage() {
[addAsset, message],
);
const createImageReversePromptNodes = useCallback(
(node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
message.warning("图片节点为空,无法反推提示词");
return;
}
const gap = 96;
const textSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config];
const centerY = node.position.y + node.height / 2;
const textNode = {
...createCanvasNode(
CanvasNodeType.Text,
{ x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY },
{ content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 },
),
title: "反推提示词",
};
const configNode = {
...createCanvasNode(
CanvasNodeType.Config,
{ x: textNode.position.x + textNode.width + gap + configSpec.width / 2, y: centerY },
{
generationMode: "text",
model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel,
count: 1,
composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`,
},
),
title: "反推提示词配置",
};
setNodes((prev) => [...prev, textNode, configNode]);
setConnections((prev) => [
...prev,
{ id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id },
{ id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id },
]);
setSelectedNodeIds(new Set([configNode.id]));
setSelectedConnectionId(null);
setDialogNodeId(configNode.id);
setContextMenu(null);
},
[effectiveConfig.model, effectiveConfig.textModel, message],
);
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
if (!node.metadata?.content) return;
const cropped = await cropDataUrl(node.metadata.content, crop);
@@ -1441,6 +1522,116 @@ function InfiniteCanvasPage() {
setCropNodeId(null);
}, []);
const splitImageNode = useCallback(
async (node: CanvasNodeData, params: CanvasImageSplitParams) => {
if (!node.metadata?.content) return;
setSplitNodeId(null);
const pieces = await splitDataUrl(node.metadata.content, params);
const gap = 16;
const cellWidth = node.width / params.columns;
const cellHeight = node.height / params.rows;
const startX = node.position.x + node.width + 96;
const startY = node.position.y;
const childNodes = await Promise.all(
pieces.map(async (piece) => {
const image = await uploadImage(piece.dataUrl);
const id = nanoid();
return {
id,
type: CanvasNodeType.Image,
title: `${node.title || "图片"} ${piece.row + 1}-${piece.column + 1}`,
position: { x: startX + piece.column * (cellWidth + gap), y: startY + piece.row * (cellHeight + gap) },
width: cellWidth,
height: cellHeight,
metadata: {
...imageMetadata(image),
prompt: node.metadata?.prompt,
},
} satisfies CanvasNodeData;
}),
);
setNodes((prev) => [...prev, ...childNodes]);
setConnections((prev) => [...prev, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]);
setSelectedNodeIds(new Set(childNodes.map((child) => child.id)));
setSelectedConnectionId(null);
setDialogNodeId(null);
message.success(`已切分为 ${childNodes.length} 个子节点`);
},
[message],
);
const maskEditImageNode = useCallback(
async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
const userPrompt = payload.prompt.trim();
const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`;
const childId = nanoid();
const source = { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
setMaskEditNodeId(null);
setRunningNodeId(childId);
setNodes((prev) => [
...prev,
{
id: childId,
type: CanvasNodeType.Image,
title: userPrompt.slice(0, 32) || "局部编辑结果",
position: { x: node.position.x + node.width + 96, y: node.position.y },
width: node.width,
height: node.height,
metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata },
},
]);
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
try {
const image = await requestEdit(generationConfig, prompt, [source], { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }).then((items) => items[0]);
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "局部修改失败";
message.error(errorDetails);
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
} finally {
setRunningNodeId(null);
}
},
[effectiveConfig, isAiConfigReady, message, openConfigDialog],
);
const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => {
if (!node.metadata?.content) return;
setUpscaleNodeId(null);
const upscaled = await upscaleDataUrl(node.metadata.content, params);
const image = await uploadImage(upscaled);
const size = fitNodeSize(image.width, image.height);
const childId = nanoid();
const child: CanvasNodeData = {
id: childId,
type: CanvasNodeType.Image,
title: "Upscaled Image",
position: { x: node.position.x + node.width + 96, y: node.position.y },
width: size.width,
height: size.height,
metadata: {
...imageMetadata(image),
prompt: node.metadata?.prompt,
},
};
setNodes((prev) => [...prev, child]);
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setDialogNodeId(childId);
}, []);
const generateAngleNode = useCallback(
async (node: CanvasNodeData, params: CanvasImageAngleParams) => {
if (!node.metadata?.content) return;
@@ -1634,12 +1825,13 @@ function InfiniteCanvasPage() {
);
const effectivePrompt = generationContext.prompt.trim();
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
setRunningNodeId(null);
return;
}
let pendingChildIds: string[] = [];
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
try {
if (mode === "image") {
@@ -1703,7 +1895,7 @@ function InfiniteCanvasPage() {
? isConfigNode
? {
...node,
metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
}
: isEmptyImageNode
? {
@@ -1854,16 +2046,16 @@ function InfiniteCanvasPage() {
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id,
type: CanvasNodeType.Text,
title: prompt.slice(0, 32) || "Generated Text",
title: effectivePrompt.slice(0, 32) || "Generated Text",
position: {
x: parentPosition.x + parentConfig.width + 96,
y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36),
},
width: textConfig.width,
height: textConfig.height,
metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
}));
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
}
@@ -2190,30 +2382,41 @@ function InfiniteCanvasPage() {
batchRecovering={collapsingBatchIds.has(node.id)}
batchMotion={batchMotionById.get(node.id)}
showImageInfo={showImageInfo}
renderPanel={(panelNode) => (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
/>
)}
resourceLabel={resourceReferenceByNodeId.get(node.id)}
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
renderPanel={(panelNode) =>
panelNode.type === CanvasNodeType.Config ? (
<CanvasConfigComposer
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
inputs={configInputsById.get(panelNode.id) || []}
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
onClose={() => setDialogNodeId(null)}
/>
) : (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || []}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
/>
)
}
renderNodeContent={(contentNode) => (
<CanvasConfigNodePanel
node={contentNode}
isRunning={runningNodeId === contentNode.id}
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
inputs={configInputsById.get(contentNode.id) || []}
onConfigChange={handleConfigNodeChange}
onTextInputChange={handleNodeContentChange}
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
onGenerate={(nodeId) => {
const target = nodesRef.current.find((item) => item.id === nodeId);
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || "");
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
}}
/>
)}
@@ -2234,6 +2437,7 @@ function InfiniteCanvasPage() {
onSetBatchPrimary={setBatchPrimary}
onRetry={(node) => void handleRetryNode(node)}
onGenerateImage={generateImageFromTextNode}
onViewImage={(node) => setPreviewNodeId(node.id)}
onContextMenu={(event, id) => {
event.preventDefault();
event.stopPropagation();
@@ -2272,9 +2476,14 @@ function InfiniteCanvasPage() {
onUpload={(node) => handleUploadRequest(node.id)}
onDownload={downloadNodeImage}
onSaveAsset={(node) => void saveNodeAsset(node)}
onMaskEdit={(node) => setMaskEditNodeId(node.id)}
onCrop={(node) => setCropNodeId(node.id)}
onSplit={(node) => setSplitNodeId(node.id)}
onUpscale={(node) => setUpscaleNodeId(node.id)}
onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
onAngle={(node) => setAngleNodeId(node.id)}
onViewImage={(node) => setPreviewNodeId(node.id)}
onReversePrompt={createImageReversePromptNodes}
onRetry={(node) => void handleRetryNode(node)}
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
onDelete={(node) => deleteNodes(new Set([node.id]))}
@@ -2339,6 +2548,16 @@ function InfiniteCanvasPage() {
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open={Boolean(maskEditNode)} onClose={() => setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null}
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open={Boolean(splitNode)} onClose={() => setSplitNodeId(null)} onConfirm={(params) => void splitImageNode(splitNode!, params)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null}
<Modal title="AI 超分" open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}>
<div className="py-8 text-center text-base font-medium"></div>
</Modal>
{angleNode?.metadata?.content ? <CanvasNodeAngleDialog dataUrl={angleNode.metadata.content} open={Boolean(angleNode)} onClose={() => setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null}
<Modal
@@ -0,0 +1,366 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import { Button, Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { NodeGenerationInput } from "./canvas-node-generation";
type CanvasConfigComposerProps = {
value: string;
inputs: NodeGenerationInput[];
onChange: (value: string) => void;
onClose: () => void;
};
type Token =
| { type: "text"; value: string }
| { type: "reference"; nodeId: string };
type MentionState = {
query: string;
};
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const tokens = useMemo(() => parseComposerTokens(value), [value]);
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
const candidates = useMemo(() => {
if (!mention) return [];
const query = (mention.query || "").trim().toLowerCase();
if (!query) return inputs;
return inputs.filter((input) => `${resourceLabel(input, inputs)} ${input.title} ${input.text || ""}`.toLowerCase().includes(query));
}, [inputs, mention]);
useEffect(() => {
if (document.activeElement === editorRef.current) return;
const editor = editorRef.current;
if (!editor) return;
editor.textContent = "";
tokens.forEach((token) => {
if (token.type === "text") {
editor.append(document.createTextNode(token.value));
return;
}
const input = referenceById.get(token.nodeId);
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
});
}, [inputs, referenceById, theme, tokens]);
const syncFromEditor = () => {
const editor = editorRef.current;
if (!editor) return;
const next = serializeEditor(editor);
onChange(next);
syncMention();
};
const syncMention = () => {
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match || !inputs.length) {
closeMention();
return;
}
setMention({ query: match[1] || "" });
setActiveIndex(0);
};
const closeMention = () => {
setMention(null);
setActiveIndex(0);
};
const insertReference = (input: NodeGenerationInput) => {
const editor = editorRef.current;
if (!editor) return;
removeActiveMention();
const chip = createReferenceChip(input, inputs, theme, setImagePreview);
const space = document.createTextNode(" ");
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (range) {
range.insertNode(space);
range.insertNode(chip);
range.setStartAfter(space);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
} else {
editor.append(chip, space);
placeCaretAtEnd(editor);
}
closeMention();
onChange(serializeEditor(editor));
};
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
return (
<div
data-canvas-no-zoom
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={stopCanvasInteraction}
onPointerDown={stopCanvasInteraction}
onWheel={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold"></div>
<div className="truncate text-[11px] opacity-55">@ </div>
</div>
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
</div>
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}> @ </div> : null}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="thin-scrollbar min-h-28 w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
style={{ color: theme.node.text }}
onInput={() => {
if (!composingRef.current) syncFromEditor();
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
syncFromEditor();
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
event.stopPropagation();
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => (index + 1) % candidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMention();
return;
}
}
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
event.preventDefault();
requestAnimationFrame(syncFromEditor);
return;
}
requestAnimationFrame(syncMention);
}}
onBlur={() => window.setTimeout(closeMention, 120)}
/>
{mention && candidates.length ? <MentionMenu inputs={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null}
</div>
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div>
);
}
function MentionMenu({ inputs, allInputs, activeIndex, theme, onSelect }: { inputs: NodeGenerationInput[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (input: NodeGenerationInput) => void }) {
const selectedRef = useRef(false);
const selectInput = (input: NodeGenerationInput) => {
if (selectedRef.current) return;
selectedRef.current = true;
onSelect(input);
};
return (
<div className="absolute left-2 top-[calc(100%+6px)] z-[90] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
{inputs.map((input, index) => (
<button
key={input.nodeId}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
selectInput(input);
}}
>
<ResourcePreview input={input} />
<span className="min-w-0 flex-1">
<span className="block font-medium">{resourceLabel(input, allInputs)}</span>
<span className="block truncate opacity-65">{input.text || input.title}</span>
</span>
</button>
))}
</div>
);
}
function ResourcePreview({ input }: { input: NodeGenerationInput }) {
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
if (input.type === "video" && input.video) return <video src={input.video.url} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "image" ? ImageIcon : FileText;
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
<Icon className="size-4" />
</span>
);
}
function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationInput[], theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
const wrapper = document.createElement("span");
wrapper.contentEditable = "false";
wrapper.dataset.referenceNodeId = input.nodeId;
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
Object.assign(wrapper.style, chipStyle(theme));
if (input.type === "image" && input.image) {
const image = document.createElement("img");
image.src = input.image.dataUrl;
image.alt = input.title;
image.className = "size-6 rounded object-cover";
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
wrapper.appendChild(image);
wrapper.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onImagePreview(input.image?.dataUrl || "");
});
} else {
wrapper.title = input.text || input.title;
const text = document.createElement("span");
text.className = "block truncate";
text.textContent = input.type === "text" ? input.text || input.title : input.title;
wrapper.appendChild(text);
}
return wrapper;
}
function serializeEditor(editor: HTMLElement) {
return serializeNodes(editor.childNodes).replace(/\uFEFF/g, "");
}
function serializeNodes(nodes: NodeListOf<ChildNode>) {
let result = "";
nodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
if (!(node instanceof HTMLElement)) return;
const nodeId = node.dataset.referenceNodeId;
if (nodeId) result += `@[node:${nodeId}]`;
else if (node.tagName === "BR") result += "\n";
else result += serializeNodes(node.childNodes);
});
return result;
}
function removeActiveMention() {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match) return;
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
range.deleteContents();
}
function deleteAdjacentReference(key: string) {
const selection = window.getSelection();
if (!selection?.rangeCount || !selection.isCollapsed) return false;
const range = selection.getRangeAt(0);
const target = adjacentReferenceNode(range, key);
if (!target) return false;
const nextCaretNode = document.createTextNode("");
target.replaceWith(nextCaretNode);
range.setStart(nextCaretNode, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function adjacentReferenceNode(range: Range, key: string) {
const container = range.startContainer;
const offset = range.startOffset;
const previous = key === "Backspace";
if (container.nodeType === Node.TEXT_NODE) {
const text = container.textContent || "";
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
return findReferenceSibling(container, previous);
}
const children = Array.from(container.childNodes);
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
}
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
return current instanceof HTMLElement && current.dataset.referenceNodeId ? current : null;
}
function textBeforeCaret() {
const selection = window.getSelection();
if (!selection?.rangeCount) return "";
const range = selection.getRangeAt(0).cloneRange();
const editor = closestEditor(range.startContainer);
if (!editor) return "";
range.setStart(editor, 0);
return range.toString();
}
function closestEditor(node: Node) {
const element = node instanceof Element ? node : node.parentElement;
return element?.closest("[contenteditable='true']") || null;
}
function placeCaretAtEnd(element: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function parseComposerTokens(value: string): Token[] {
const tokens: Token[] = [];
let lastIndex = 0;
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
if (match.index === undefined) continue;
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
tokens.push({ type: "reference", nodeId: match[1] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
return tokens;
}
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
if (input.type === "image") return `图片${index + 1}`;
if (input.type === "video") return `视频${index + 1}`;
if (input.type === "audio") return `音频${index + 1}`;
return `文本${index + 1}`;
}
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
return { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
}
@@ -1,38 +1,29 @@
"use client";
import type { CSSProperties } from "react";
import { useState } from "react";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Video } from "lucide-react";
import { App, Button, Empty, Input, Modal, Segmented } from "antd";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react";
import { Button, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import type { NodeGenerationInput } from "./canvas-node-generation";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
type CanvasConfigNodePanelProps = {
node: CanvasNodeData;
isRunning: boolean;
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
inputs: NodeGenerationInput[];
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onTextInputChange: (nodeId: string, content: string) => void;
onGenerate: (nodeId: string) => void;
onComposerToggle: () => void;
};
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
const { message } = App.useApp();
const [previewOpen, setPreviewOpen] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle }: CanvasConfigNodePanelProps) {
const globalConfig = useEffectiveConfig();
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
@@ -42,36 +33,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const textInputs = inputs.filter((input) => input.type === "text");
const imageInputs = inputs.filter((input) => input.type === "image");
const videoInputs = inputs.filter((input) => input.type === "video");
const audioInputs = inputs.filter((input) => input.type === "audio");
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const canGenerate = mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput;
const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
const targetInput = sameTypeInputs[sameTypeIndex + offset];
if (!targetInput) return;
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
const next = [...inputs];
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
message.success("已调整输入顺序");
};
const startTextEdit = (input: NodeGenerationInput) => {
setEditingTextId(input.nodeId);
setEditingText(input.text || "");
};
const saveTextEdit = () => {
if (!editingTextId) return;
onTextInputChange(editingTextId, editingText);
setEditingText("");
setEditingTextId(null);
message.success("已保存文本提示词");
};
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput);
return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
@@ -125,14 +89,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
</div>
</div>
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
<div className="mb-2 flex flex-wrap gap-1.5">
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount}`} style={chipStyle} />
<InputChip label="参考音频" value={`${inputSummary.audioCount}`} style={chipStyle} />
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
<Eye className="size-3.5" />
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
<Settings2 className="size-3.5" />
</button>
</div>
@@ -163,238 +127,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<span></span>
</span>
</Button>
<Modal
title="输入预览"
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={null}
centered
width={860}
mask={{ closable: true }}
keyboard
destroyOnHidden
modalRender={(modal) => (
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{modal}
</div>
)}
>
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
{inputs.length ? (
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
<div className="shrink-0">
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{imageInputs.map((input, index) => (
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="shrink-0">
<PreviewSection title="参考视频" count={videoInputs.length} empty="暂无参考视频">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{videoInputs.map((input, index) => (
<VideoSortCard key={input.nodeId} input={input} videoIndex={index} videoTotal={videoInputs.length} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="shrink-0">
<PreviewSection title="参考音频" count={audioInputs.length} empty="暂无参考音频">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{audioInputs.map((input, index) => (
<AudioSortCard key={input.nodeId} input={input} audioIndex={index} audioTotal={audioInputs.length} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
<div className="space-y-1.5">
{textInputs.map((input, index) => (
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
))}
</div>
</PreviewSection>
</div>
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
{editingTextId ? (
<>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold"></div>
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>
</Button>
</div>
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
<div className="mt-2 flex justify-end gap-2">
<Button size="small" onClick={() => setEditingTextId(null)}>
</Button>
<Button size="small" type="primary" onClick={saveTextEdit}>
</Button>
</div>
</>
) : (
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
<Edit3 className="mx-auto mb-2 size-5" />
</div>
)}
</div>
</div>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
)}
</div>
</Modal>
</div>
);
}
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
return (
<section>
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
<div className="text-xs font-semibold">{title}</div>
<div className="text-[11px] opacity-50">{count} </div>
</div>
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
</section>
);
}
function TextSortCard({
input,
textIndex,
textTotal,
inputs,
theme,
onMove,
onEdit,
}: {
input: NodeGenerationInput;
textIndex: number;
textTotal: number;
inputs: NodeGenerationInput[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
onEdit: (input: NodeGenerationInput) => void;
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
<div className="min-w-0">
<div className="truncate text-[10px] font-medium opacity-50"> {textIndex + 1}</div>
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
</div>
<div className="flex justify-end gap-1">
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function ImageSortCard({
input,
imageIndex,
imageTotal,
inputs,
theme,
onMove,
}: {
input: NodeGenerationInput;
imageIndex: number;
imageTotal: number;
inputs: NodeGenerationInput[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.image) return null;
return (
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageReferenceLabel(imageIndex)}</span>
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function VideoSortCard({
input,
videoIndex,
videoTotal,
theme,
onMove,
}: {
input: NodeGenerationInput;
videoIndex: number;
videoTotal: number;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.video) return null;
return (
<div className="w-32 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<video src={input.video.url} className="aspect-video w-full bg-black object-cover" muted preload="metadata" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{seedanceReferenceLabel("video", videoIndex)}</span>
<HorizontalOrderButtons index={videoIndex} total={videoTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function AudioSortCard({
input,
audioIndex,
audioTotal,
theme,
onMove,
}: {
input: NodeGenerationInput;
audioIndex: number;
audioTotal: number;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.audio) return null;
return (
<div className="w-48 shrink-0 rounded-lg border p-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 text-[11px] opacity-70">
<Music2 className="size-3.5 shrink-0" />
<span className="truncate">{input.title}</span>
</div>
<audio src={input.audio.url} controls className="h-8 w-full" preload="metadata" />
<div className="mt-1 flex justify-between">
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowLeft className="size-3" />} disabled={audioIndex <= 0} onClick={() => onMove(input, -1)} />
<span className="text-[10px] opacity-45">{seedanceReferenceLabel("audio", audioIndex)}</span>
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowRight className="size-3" />} disabled={audioIndex >= audioTotal - 1} onClick={() => onMove(input, 1)} />
</div>
</div>
);
}
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return (
<>
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</>
);
}
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return (
<div className="absolute inset-x-1 bottom-1 flex justify-between">
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</div>
);
}
@@ -0,0 +1,231 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd";
import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react";
import type { ImageQuickToolId } from "./canvas-image-toolbar-tools";
export type ImageToolbarSettingsTool = {
id: ImageQuickToolId;
title: string;
label: string;
icon: ReactNode;
active?: boolean;
danger?: boolean;
};
type PreviewTool = ImageToolbarSettingsTool | {
id: "more";
title: string;
label: string;
icon: ReactNode;
active?: boolean;
danger?: boolean;
};
type PreviewScroll = {
left: number;
max: number;
viewport: number;
content: number;
};
export function ImageToolSettingsModal({
open,
tools,
selectedIds,
showLabels,
onToggle,
onShowLabelsChange,
onCancel,
onSave,
}: {
open: boolean;
tools: ImageToolbarSettingsTool[];
selectedIds: ImageQuickToolId[];
showLabels: boolean;
onToggle: (id: ImageQuickToolId, visible: boolean) => void;
onShowLabelsChange: (value: boolean) => void;
onCancel: () => void;
onSave: () => void;
}) {
const { token } = antdTheme.useToken();
const previewToolbarRef = useRef<HTMLDivElement>(null);
const scrollbarTrackRef = useRef<HTMLInputElement>(null);
const [previewScroll, setPreviewScroll] = useState<PreviewScroll>({ left: 0, max: 0, viewport: 1, content: 1 });
const selected = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedTools = tools.filter((tool) => selected.has(tool.id));
const previewTools: PreviewTool[] = [
...selectedTools,
{ id: "more", title: "配置快捷工具", label: "更多", icon: <Ellipsis className="size-4" />, active: true },
];
const syncPreviewScroll = useCallback(() => {
const toolbar = previewToolbarRef.current;
if (!toolbar) return;
setPreviewScroll({
left: toolbar.scrollLeft,
max: Math.max(0, toolbar.scrollWidth - toolbar.clientWidth),
viewport: Math.max(1, toolbar.clientWidth),
content: Math.max(1, toolbar.scrollWidth),
});
}, []);
const setPreviewScrollLeft = useCallback(
(left: number) => {
const toolbar = previewToolbarRef.current;
if (!toolbar) return;
toolbar.scrollLeft = left;
syncPreviewScroll();
},
[syncPreviewScroll],
);
const updateSelectedTools = (values: ImageQuickToolId[]) => {
const next = new Set(values);
tools.forEach((tool) => {
const visible = next.has(tool.id);
if (selected.has(tool.id) !== visible) onToggle(tool.id, visible);
});
};
useEffect(() => {
if (!open) return;
const toolbar = previewToolbarRef.current;
const sync = () => syncPreviewScroll();
const frames: number[] = [];
const firstFrame = window.requestAnimationFrame(() => {
sync();
frames.push(window.requestAnimationFrame(sync));
});
frames.push(firstFrame);
const timer = window.setTimeout(sync, 120);
const resizeObserver = typeof ResizeObserver !== "undefined" && toolbar ? new ResizeObserver(sync) : null;
resizeObserver?.observe(toolbar);
toolbar?.childNodes.forEach((child) => {
if (child instanceof Element) resizeObserver?.observe(child);
});
sync();
window.addEventListener("resize", syncPreviewScroll);
return () => {
frames.forEach((frame) => window.cancelAnimationFrame(frame));
window.clearTimeout(timer);
resizeObserver?.disconnect();
window.removeEventListener("resize", syncPreviewScroll);
};
}, [open, selectedIds, showLabels, previewTools.length, syncPreviewScroll]);
const scrollbarWidth = scrollbarTrackRef.current?.clientWidth || previewScroll.viewport;
const scrollbarThumbWidth = previewScroll.max > 0 ? Math.min(scrollbarWidth, Math.max(64, (previewScroll.viewport / previewScroll.content) * scrollbarWidth)) : scrollbarWidth;
return (
<Modal
title="自定义工具栏"
open={open}
centered
width={760}
onCancel={onCancel}
destroyOnHidden
footer={
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span></span>
<Switch checked={showLabels} onChange={onShowLabelsChange} />
</div>
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={onSave}>
</Button>
</Space>
</div>
}
>
<Typography.Paragraph type="secondary" className="!mb-4">
使
</Typography.Paragraph>
<Card
size="small"
title={
<Space size={6}>
<Settings2 className="size-4" />
</Space>
}
className="mb-4"
>
<div className="relative flex min-h-[300px] w-full justify-center pt-20 pb-9">
<div
ref={previewToolbarRef}
className="hide-scrollbar absolute left-2 right-2 top-3 z-10 flex h-12 items-center overflow-x-auto rounded-[18px] border px-1 text-[13px]"
style={{ background: token.colorBgElevated, borderColor: token.colorBorderSecondary, boxShadow: token.boxShadowSecondary, color: token.colorText }}
onScroll={syncPreviewScroll}
>
{previewTools.map((tool) => (
<PreviewToolbarItem key={tool.id} tool={tool} showLabels={showLabels} />
))}
</div>
<div
className="flex h-48 w-full max-w-[360px] flex-col items-center justify-center rounded-xl border"
style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }}
>
<ImageIcon className="mb-2 size-8" />
<Typography.Text type="secondary"></Typography.Text>
</div>
<input
ref={scrollbarTrackRef}
type="range"
min={0}
max={Math.max(previewScroll.max, 1)}
value={Math.min(previewScroll.left, Math.max(previewScroll.max, 1))}
disabled={previewScroll.max <= 0}
className="absolute bottom-4 left-10 right-10 h-2.5 cursor-pointer appearance-none bg-transparent disabled:cursor-default [&::-moz-range-thumb]:h-2.5 [&::-moz-range-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[#8d9498] [&::-moz-range-track]:h-2.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[#bdc4c8] [&::-webkit-slider-runnable-track]:h-2.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[#bdc4c8] [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[#8d9498]"
style={{ "--preview-scrollbar-thumb-width": `${scrollbarThumbWidth}px` } as CSSProperties}
onInput={(event) => setPreviewScrollLeft(Number(event.currentTarget.value))}
onChange={(event) => setPreviewScrollLeft(Number(event.target.value))}
/>
</div>
</Card>
<Form layout="vertical" className="!mb-0">
<Form.Item
className="!mb-4"
label={
<Space size={8}>
<span></span>
<Tag className="m-0">
{selectedTools.length}/{tools.length}
</Tag>
</Space>
}
>
<Checkbox.Group value={selectedIds} className="grid w-full gap-3 md:grid-cols-3" onChange={(values) => updateSelectedTools(values as ImageQuickToolId[])}>
{tools.map((tool) => (
<Checkbox key={tool.id} value={tool.id} className="m-0">
<span className="inline-flex items-center gap-2">
{tool.icon}
{tool.label}
</span>
</Checkbox>
))}
</Checkbox.Group>
</Form.Item>
</Form>
</Modal>
);
}
function PreviewToolbarItem({ tool, showLabels }: { tool: PreviewTool; showLabels: boolean }) {
return (
<Tooltip title={tool.title}>
<span className="flex h-12 shrink-0 items-center px-1.5" style={{ color: tool.danger ? "#ef4444" : undefined }}>
<span className={`flex h-9 items-center rounded-lg px-2 ${showLabels ? "gap-2" : "justify-center"}`}>
{tool.icon}
{showLabels ? <span className="whitespace-nowrap">{tool.label}</span> : null}
</span>
</span>
</Tooltip>
);
}
@@ -0,0 +1,179 @@
"use client";
import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react";
import type { CanvasNodeData } from "../types";
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
export type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
onSplit: (node: CanvasNodeData) => void;
onUpscale: (node: CanvasNodeData) => void;
onSuperResolve: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onCopyPrompt: (node: CanvasNodeData) => void;
onReversePrompt: (node: CanvasNodeData) => void;
};
export type ImageToolDefinition = {
id: ImageNodeActionToolId;
defaultVisible: boolean;
panelLabel: string;
label: string | ((node: CanvasNodeData) => string);
title: string | ((node: CanvasNodeData) => string);
icon: (node: CanvasNodeData) => ReactNode;
active?: (node: CanvasNodeData) => boolean;
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
};
export type ImageQuickToolsConfig = {
ids: ImageQuickToolId[];
showLabels: boolean;
};
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v6";
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
export const imageToolDefinitions: ImageToolDefinition[] = [
{
id: "copyPrompt",
defaultVisible: true,
panelLabel: "复制提示词",
label: "复制提示词",
title: "复制生成该图片的提示词",
icon: () => <Copy className="size-4" />,
run: (node, handlers) => handlers.onCopyPrompt(node),
},
{
id: "reversePrompt",
defaultVisible: true,
panelLabel: "反推提示词",
label: "反推提示词",
title: "创建反推提示词的文本和配置节点",
icon: () => <FileText className="size-4" />,
run: (node, handlers) => handlers.onReversePrompt(node),
},
{
id: "replace",
defaultVisible: true,
panelLabel: "替换图片",
label: "替换图片",
title: "替换图片",
icon: () => <Upload className="size-4" />,
run: (node, handlers) => handlers.onUpload(node),
},
{
id: "resize",
defaultVisible: false,
panelLabel: "锁比例",
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"),
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />),
active: (node) => Boolean(node.metadata?.freeResize),
run: (node, handlers) => handlers.onToggleFreeResize(node),
},
{
id: "maskEdit",
defaultVisible: true,
panelLabel: "局部编辑",
label: "局部编辑",
title: "添加蒙版遮罩后局部修改",
icon: () => <Brush className="size-4" />,
run: (node, handlers) => handlers.onMaskEdit(node),
},
{
id: "crop",
defaultVisible: true,
panelLabel: "裁剪",
label: "裁剪",
title: "裁剪并生成新节点",
icon: () => <Scissors className="size-4" />,
run: (node, handlers) => handlers.onCrop(node),
},
{
id: "split",
defaultVisible: true,
panelLabel: "切图",
label: "切图",
title: "按行列切分图片",
icon: () => <Grid2x2 className="size-4" />,
run: (node, handlers) => handlers.onSplit(node),
},
{
id: "upscale",
defaultVisible: true,
panelLabel: "放大",
label: "放大",
title: "放大图片分辨率",
icon: () => <ZoomIn className="size-4" />,
run: (node, handlers) => handlers.onUpscale(node),
},
{
id: "superResolve",
defaultVisible: false,
panelLabel: "超分",
label: "超分",
title: "AI 超分",
icon: () => <Sparkles className="size-4" />,
run: (node, handlers) => handlers.onSuperResolve(node),
},
{
id: "angle",
defaultVisible: false,
panelLabel: "多角度",
label: "多角度",
title: "生成角度",
icon: () => <Camera className="size-4" />,
run: (node, handlers) => handlers.onAngle(node),
},
{
id: "view",
defaultVisible: true,
panelLabel: "查看大图",
label: "查看大图",
title: "查看图片详情",
icon: () => <Maximize2 className="size-4" />,
run: (node, handlers) => handlers.onViewImage(node),
},
];
export const defaultImageQuickToolIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.filter((tool) => tool.defaultVisible).map((tool) => tool.id)];
export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) {
return imageToolDefinitions.map((tool) => ({
id: tool.id,
label: resolveToolText(tool.label, node),
title: resolveToolText(tool.title, node),
icon: tool.icon(node),
active: tool.active?.(node),
onClick: () => tool.run(node, handlers),
}));
}
export function normalizeImageQuickToolIds(value: unknown[]) {
const allIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.map((tool) => tool.id)];
const ids = new Set(allIds);
return allIds.filter((id) => value.includes(id) && ids.has(id));
}
export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig {
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: true };
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: true };
const data = value as Partial<ImageQuickToolsConfig>;
return {
ids: Array.isArray(data.ids) ? normalizeImageQuickToolIds(data.ids) : defaultImageQuickToolIds,
showLabels: data.showLabels !== false,
};
}
function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) {
return typeof value === "function" ? value(node) : value;
}
@@ -1,7 +1,10 @@
import type { ChatCompletionMessage } from "@/services/api/image";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
import { getGenerationResourceNodes } from "../utils/canvas-resource-references";
export type NodeGenerationContext = {
prompt: string;
@@ -26,6 +29,11 @@ export type NodeGenerationInput = {
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
const sourceNode = nodes.find((node) => node.id === nodeId);
if (sourceNode?.type === CanvasNodeType.Config && Boolean(sourceNode.metadata?.composerContent?.trim())) {
return buildComposerGenerationContext(inputs, prompt);
}
const upstreamText = inputs
.map((input) => input.text)
.filter(Boolean)
@@ -46,8 +54,67 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
};
}
function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: string): NodeGenerationContext {
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
const selectedInputs: NodeGenerationInput[] = [];
const labelByNodeId = new Map<string, string>();
const textBlocks: string[] = [];
const counts = { image: 0, video: 0, audio: 0, text: 0 };
let hasToken = false;
let lastIndex = 0;
let nextPrompt = "";
for (const match of prompt.matchAll(/@\[node:([^\]]+)\]/g)) {
if (match.index === undefined) continue;
hasToken = true;
nextPrompt += prompt.slice(lastIndex, match.index);
const input = inputByNodeId.get(match[1]);
if (input) {
let label = labelByNodeId.get(input.nodeId);
if (!label) {
label = generationLabel(input.type, counts[input.type]++);
labelByNodeId.set(input.nodeId, label);
if (input.type === "text") textBlocks.push(`${label}\n${input.text || ""}`);
else selectedInputs.push(input);
}
nextPrompt += input.type === "text" ? `${label}` : label;
}
lastIndex = match.index + match[0].length;
}
nextPrompt += prompt.slice(lastIndex);
if (textBlocks.length) nextPrompt = `${nextPrompt.trim()}\n\n${textBlocks.join("\n\n")}`;
const referenceImages = selectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = selectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
const referenceAudios = selectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
if (!hasToken) {
return {
prompt,
referenceImages: [],
referenceVideos: [],
referenceAudios: [],
textCount: 0,
imageCount: 0,
videoCount: 0,
audioCount: 0,
};
}
return {
prompt: nextPrompt,
referenceImages,
referenceVideos,
referenceAudios,
textCount: counts.text,
imageCount: referenceImages.length,
videoCount: referenceVideos.length,
audioCount: referenceAudios.length,
};
}
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
return getGenerationResourceNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
const image = readReferenceImage(node);
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
const video = readReferenceVideo(node);
@@ -83,6 +150,13 @@ function readNodeTextInput(node: CanvasNodeData) {
return node.metadata?.prompt || "";
}
function generationLabel(type: NodeGenerationInput["type"], index: number) {
if (type === "image") return imageReferenceLabel(index);
if (type === "video") return seedanceReferenceLabel("video", index);
if (type === "audio") return seedanceReferenceLabel("audio", index);
return `文本${index + 1}`;
}
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
return {
@@ -120,13 +194,3 @@ function readReferenceAudio(node: CanvasNodeData): ReferenceAudio | null {
durationMs: node.metadata.durationMs,
};
}
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const target = nodes.find((node) => node.id === nodeId);
const upstreamNodes = connections
.filter((connection) => connection.toNodeId === nodeId)
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const order = target?.metadata?.inputOrder || [];
return [...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)), ...upstreamNodes.filter((node) => !order.includes(node.id))];
}
@@ -1,13 +1,16 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Modal, Segmented, Tooltip } from "antd";
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload, Video } from "lucide-react";
import { App, Modal, Segmented, Tooltip } from "antd";
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
type CanvasNodeHoverToolbarProps = {
node: CanvasNodeData | null;
@@ -23,14 +26,29 @@ type CanvasNodeHoverToolbarProps = {
onUpload: (node: CanvasNodeData) => void;
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
onSplit: (node: CanvasNodeData) => void;
onUpscale: (node: CanvasNodeData) => void;
onSuperResolve: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onReversePrompt: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
};
type ToolbarTool = {
id: string;
title: string;
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
danger?: boolean;
};
export function CanvasNodeHoverToolbar({
node,
viewport,
@@ -45,13 +63,43 @@ export function CanvasNodeHoverToolbar({
onUpload,
onDownload,
onSaveAsset,
onMaskEdit,
onCrop,
onSplit,
onUpscale,
onSuperResolve,
onAngle,
onViewImage,
onReversePrompt,
onRetry,
onToggleFreeResize,
onDelete,
}: CanvasNodeHoverToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
const { message } = App.useApp();
const copyText = useCopyText();
useEffect(() => {
try {
const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored) as unknown;
const config = readImageQuickToolsConfig(parsed);
setQuickImageToolIds(config.ids);
setShowImageToolLabels(config.showLabels);
} catch {
window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
}
}, []);
useEffect(() => {
setImageToolSettingsOpen(false);
}, [node?.id]);
if (!node) return null;
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
@@ -66,45 +114,98 @@ export function CanvasNodeHoverToolbar({
const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isText || hasImage || isVideo;
const canRetry = node.metadata?.status === "error";
const hasSpecificTools = canRetry || isText || isImage || isVideo || isAudio || isConfig;
const quickImageToolIdSet = new Set(quickImageToolIds);
const copyImagePrompt = (target: CanvasNodeData) => {
const prompt = target.metadata?.prompt?.trim();
if (!prompt) {
message.warning("暂无可复制的提示词");
return;
}
copyText(prompt, "提示词已复制");
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
function openImageToolSettings() {
onKeep(node.id);
setDraftImageToolIds(quickImageToolIds);
setDraftShowImageToolLabels(showImageToolLabels);
setImageToolSettingsOpen(true);
}
const baseToolbarTools: ToolbarTool[] = [
{ id: "info", title: "查看节点信息", label: "信息", icon: <Info className="size-4" />, onClick: () => onInfo(node) },
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
];
const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
const closeImageToolSettings = () => {
setImageToolSettingsOpen(false);
onLeave();
};
const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => {
setDraftImageToolIds((current) => {
const selected = new Set(current);
if (visible) selected.add(id);
else selected.delete(id);
return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id);
});
};
const saveImageToolSettings = () => {
const config = { ids: draftImageToolIds, showLabels: draftShowImageToolLabels };
setQuickImageToolIds(config.ids);
setShowImageToolLabels(config.showLabels);
window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(config));
closeImageToolSettings();
};
return (
<div
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
style={{ left, top }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={onLeave}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
{hasSpecificTools ? <ToolbarDivider /> : null}
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
{hasImage || hasVideo || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
{hasImage || hasVideo || hasAudio ? <IconAction title={hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
{isVideo ? <ToolbarAction title={hasVideo ? "替换视频" : "上传视频"} label={hasVideo ? "替换视频" : "上传视频"} icon={<Video className="size-4" />} onClick={() => onUpload(node)} /> : null}
{isAudio ? <ToolbarAction title={hasAudio ? "替换音频" : "上传音频"} label={hasAudio ? "替换音频" : "上传音频"} icon={<Music2 className="size-4" />} onClick={() => onUpload(node)} /> : null}
<>
<div
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
style={{ left, top }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={() => {
if (!imageToolSettingsOpen) onLeave();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{toolbarTools.map((tool) => (
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
))}
{hasImage ? <ToolbarAction id="more" title="配置快捷工具" label="更多" icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
</div>
{hasImage ? (
<ToolbarAction
title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
label={node.metadata?.freeResize ? "自由比例" : "锁比例"}
icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
onClick={() => onToggleFreeResize(node)}
active={node.metadata?.freeResize}
<ImageToolSettingsModal
open={imageToolSettingsOpen}
tools={selectableImageToolbarTools}
selectedIds={draftImageToolIds}
showLabels={draftShowImageToolLabels}
onToggle={setDraftImageToolVisible}
onShowLabelsChange={setDraftShowImageToolLabels}
onCancel={closeImageToolSettings}
onSave={saveImageToolSettings}
/>
) : null}
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
</div>
</>
);
}
@@ -178,34 +279,20 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
);
}
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
function ToolbarAction({ title, label, icon, onClick, showLabel, active = false, danger = false }: ToolbarTool & { showLabel: boolean }) {
const hasText = showLabel && Boolean(label);
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<Tooltip title={title} placement="top" mouseEnterDelay={0.2} color="#ffffff" styles={{ body: { color: "#242529", boxShadow: "0 8px 24px rgba(15,23,42,.16)", fontSize: 13, fontWeight: 500 } }}>
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
<span className={`flex h-9 items-center ${hasText ? "gap-2 px-2.5" : "justify-center px-2"} rounded-lg transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
{icon}
<span>{label}</span>
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
{hasText ? <span>{label}</span> : null}
</span>
</button>
</Tooltip>
);
}
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
</button>
</Tooltip>
);
}
function ToolbarDivider() {
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
}
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
@@ -0,0 +1,279 @@
"use client";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Slider } from "antd";
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
export type CanvasImageMaskEditPayload = {
prompt: string;
maskDataUrl: string;
};
type DrawMode = "paint" | "erase";
const defaultBrushSize = 100;
const maskFillColor = "rgba(37, 99, 235, .38)";
const maskBorderColor = "rgba(255, 255, 255, .72)";
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(defaultBrushSize);
const [mode, setMode] = useState<DrawMode>("paint");
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setPrompt("");
setBrushSize(defaultBrushSize);
setMode("paint");
setError("");
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
clearCanvas(maskCanvasRef.current);
clearCanvas(previewCanvasRef.current);
}, [image]);
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
const maskCanvas = maskCanvasRef.current;
const context = maskCanvas?.getContext("2d");
if (!context) return;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = brushSize;
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out";
context.strokeStyle = "#000";
context.fillStyle = "#000";
if (!drawingRef.current.last) {
drawMaskStroke(context, point, point, brushSize);
} else {
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
}
renderMaskPreview(maskCanvas, previewCanvasRef.current);
drawingRef.current.last = point;
if (mode === "paint") {
setError("");
}
};
const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
drawingRef.current = { active: true, last: null };
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current);
draw(event);
};
const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current.active) return;
event.preventDefault();
draw(event);
};
const stopDraw = () => {
drawingRef.current = { active: false, last: null };
const maskCanvas = maskCanvasRef.current;
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas));
};
const resetMask = () => {
clearCanvas(maskCanvasRef.current);
clearCanvas(previewCanvasRef.current);
setError("");
};
const submit = () => {
const nextPrompt = prompt.trim();
const canvas = maskCanvasRef.current;
if (!nextPrompt) return setError("请输入修改要求");
if (!canvas) return;
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) });
};
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
<canvas
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
onPointerCancel={stopDraw}
/>
</>
) : null}
</div>
</div>
<div className="flex min-h-[360px] flex-col gap-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
</Button>
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75"></span>
<span className="font-semibold">{brushSize}px</span>
</div>
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75"></div>
<Input.TextArea
rows={6}
value={prompt}
status={error && !prompt.trim() ? "error" : undefined}
placeholder="例如:把选中区域改成金属材质,保持原图光影"
onChange={(event) => {
setPrompt(event.target.value);
setError("");
}}
/>
{error ? <div className="text-xs font-medium text-[#ef4444]">{error}</div> : null}
</div>
<div className="mt-auto flex items-center justify-between gap-2">
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
</Button>
<div className="flex items-center gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>
</Button>
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
AI
</Button>
</div>
</div>
</div>
</div>
</Modal>
);
}
function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
const rect = canvas.getBoundingClientRect();
return {
x: ((clientX - rect.left) / Math.max(1, rect.width)) * canvas.width,
y: ((clientY - rect.top) / Math.max(1, rect.height)) * canvas.height,
};
}
function clearCanvas(canvas: HTMLCanvasElement | null) {
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
}
function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, size: number) {
if (from.x === to.x && from.y === to.y) {
context.beginPath();
context.arc(to.x, to.y, size / 2, 0, Math.PI * 2);
context.fill();
return;
}
context.beginPath();
context.moveTo(from.x, from.y);
context.lineTo(to.x, to.y);
context.stroke();
}
function canvasHasPaint(canvas: HTMLCanvasElement) {
const context = canvas.getContext("2d");
if (!context) return false;
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
for (let index = 3; index < data.length; index += 4) {
if (data[index] > 0) return true;
}
return false;
}
function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) {
const context = previewCanvas?.getContext("2d");
if (!previewCanvas || !context) return;
context.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
context.fillStyle = maskFillColor;
context.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
context.globalCompositeOperation = "destination-in";
context.drawImage(maskCanvas, 0, 0);
context.globalCompositeOperation = "source-over";
if (withBorder) drawDashedMaskBorder(context, maskCanvas);
}
function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) {
const maskContext = maskCanvas.getContext("2d");
if (!maskContext) return;
const { width, height } = maskCanvas;
const data = maskContext.getImageData(0, 0, width, height).data;
const step = Math.max(1, Math.round(Math.max(width, height) / 1200));
const dash = step * 8;
const gap = step * 5;
const period = dash + gap;
context.save();
context.fillStyle = maskBorderColor;
context.shadowColor = "rgba(0, 0, 0, .24)";
context.shadowBlur = step * 1.5;
for (let y = step; y < height - step; y += step) {
for (let x = step; x < width - step; x += step) {
const offset = (y * width + x) * 4 + 3;
if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue;
if ((x + y) % period > dash) continue;
context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step));
}
}
context.restore();
}
function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) {
return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0;
}
function buildEditMask(selectionCanvas: HTMLCanvasElement) {
const canvas = document.createElement("canvas");
canvas.width = selectionCanvas.width;
canvas.height = selectionCanvas.height;
const context = canvas.getContext("2d");
if (!context) return selectionCanvas.toDataURL("image/png");
const selectionContext = selectionCanvas.getContext("2d");
context.fillStyle = "#fff";
context.fillRect(0, 0, canvas.width, canvas.height);
if (!selectionContext) return canvas.toDataURL("image/png");
const selection = selectionContext.getImageData(0, 0, canvas.width, canvas.height);
const mask = context.getImageData(0, 0, canvas.width, canvas.height);
for (let index = 3; index < mask.data.length; index += 4) {
if (selection.data[index] > 0) mask.data[index] = 0;
}
context.putImageData(mask, 0, 0);
return canvas.toDataURL("image/png");
}
@@ -12,8 +12,10 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
@@ -23,10 +25,11 @@ type CanvasNodePromptPanelProps = {
onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
@@ -63,14 +66,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<textarea
<CanvasResourceMentionTextarea
value={prompt}
onChange={(event) => updatePrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault();
submit();
}}
references={mentionReferences}
onChange={updatePrompt}
onSubmit={submit}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2 } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "../utils/canvas-image-data";
export type CanvasImageSplitParams = ImageSplitParams;
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 };
const maxGridSize = 12;
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const total = params.rows * params.columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null;
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setImage(null);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => {
setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) }));
};
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="mt-1 text-sm opacity-60"> {total} </p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid rows={params.rows} columns={params.columns} />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div>
</div>
<div className="space-y-5 py-2">
<NumberField label="行数" value={params.rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={params.columns} onChange={(value) => update("columns", value)} />
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{total} </span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
</div>
</div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(params)}>
</Button>
</div>
</div>
</div>
</Modal>
);
}
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: string | number | null) => void }) {
return (
<label className="block space-y-2">
<span className="font-medium opacity-75">{label}</span>
<InputNumber className="w-full" min={1} max={maxGridSize} precision={0} value={value} onChange={onChange} />
</label>
);
}
function SplitGrid({ rows, columns }: CanvasImageSplitParams) {
return (
<div className="pointer-events-none absolute inset-0">
{Array.from({ length: columns - 1 }).map((_, index) => (
<div key={`column-${index}`} className="absolute inset-y-0 border-l border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${((index + 1) / columns) * 100}%` }} />
))}
{Array.from({ length: rows - 1 }).map((_, index) => (
<div key={`row-${index}`} className="absolute inset-x-0 border-t border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ top: `${((index + 1) / rows) * 100}%` }} />
))}
</div>
);
}
function clampGrid(value: string | number) {
const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
}
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Modal, Segmented } from "antd";
import { ImagePlus } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "../utils/canvas-image-data";
export type CanvasImageUpscaleParams = ImageUpscaleParams;
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
{ value: "high", title: "高清插值", description: "适合照片和细节图" },
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
];
const targetOptions = [
{ label: "1K", value: 1024 },
{ label: "2K", value: 2048 },
{ label: "4K", value: MAX_UPSCALE_LONG_EDGE },
];
const defaultParams: CanvasImageUpscaleParams = {
targetLongEdge: 2048,
algorithm: "high",
};
export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) {
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const sourceLongEdge = image ? Math.max(image.width, image.height) : 0;
const outputSize = useMemo(() => (image ? resolveUpscaleSize(image.width, image.height, params.targetLongEdge) : null), [image, params.targetLongEdge]);
const canUpscale = Boolean(image && sourceLongEdge < params.targetLongEdge && params.targetLongEdge <= MAX_UPSCALE_LONG_EDGE);
const reachedMax = Boolean(image && sourceLongEdge >= MAX_UPSCALE_LONG_EDGE);
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setImage(null);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
if (!image) return;
const nextTarget = targetOptions.find((option) => sourceLongEdge < option.value)?.value || MAX_UPSCALE_LONG_EDGE;
setParams((current) => ({ ...current, targetLongEdge: nextTarget }));
}, [image, sourceLongEdge]);
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold"></h2>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[280px] place-items-center rounded-lg bg-black/5">
<img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} />
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div>
</div>
<div className="space-y-6 py-2">
<div className="space-y-2">
<div className="font-medium opacity-75"></div>
<Segmented
block
value={params.targetLongEdge}
options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))}
onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))}
/>
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}</div> : null}
</div>
<div className="space-y-2">
<div className="font-medium opacity-75"></div>
<Segmented
block
value={params.algorithm}
options={algorithms.map((item) => ({
value: item.value,
label: (
<span className="flex min-h-12 flex-col justify-center text-left leading-5">
<span className="font-medium">{item.title}</span>
<span className="text-xs opacity-55">{item.description}</span>
</span>
),
}))}
onChange={(value) => setParams((current) => ({ ...current, algorithm: value as ImageUpscaleAlgorithm }))}
/>
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
</Button>
</div>
</div>
</Modal>
);
}
@@ -7,7 +7,9 @@ import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const selectionBlue = "#2f80ff";
@@ -23,6 +25,8 @@ type CanvasNodeProps = {
editRequestNonce?: number;
showPanel: boolean;
showImageInfo: boolean;
resourceLabel?: CanvasResourceReference;
mentionReferences?: CanvasResourceReference[];
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
@@ -41,6 +45,7 @@ type CanvasNodeProps = {
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onViewImage?: (node: CanvasNodeData) => void;
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
};
@@ -57,6 +62,7 @@ type NodeContentRendererProps = {
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
@@ -74,6 +80,8 @@ export const CanvasNode = React.memo(function CanvasNode({
editRequestNonce = 0,
showPanel,
showImageInfo,
resourceLabel,
mentionReferences = [],
renderPanel,
renderNodeContent,
batchCount = 0,
@@ -92,6 +100,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onSetBatchPrimary,
onRetry,
onGenerateImage,
onViewImage,
onContextMenu,
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -262,6 +271,11 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch?.(data.id);
return;
}
if (data.type === CanvasNodeType.Image && hasImageContent) {
event.stopPropagation();
onViewImage?.(data);
return;
}
if (data.type !== CanvasNodeType.Text) return;
event.stopPropagation();
setIsEditingContent(true);
@@ -291,6 +305,7 @@ export const CanvasNode = React.memo(function CanvasNode({
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
mentionReferences={mentionReferences}
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
onRetry={onRetry}
@@ -301,6 +316,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
@@ -313,7 +329,7 @@ export const CanvasNode = React.memo(function CanvasNode({
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
{showPanel && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
);
});
@@ -325,7 +341,7 @@ function NodeContent(props: NodeContentRendererProps) {
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return <Renderer {...props} />;
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
}
const nodeContentRenderers = {
@@ -366,7 +382,18 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
);
}
function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
return (
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
</div>
);
}
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
const fontSize = node.metadata?.fontSize || 14;
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties;
return (
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
<button
@@ -386,12 +413,14 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
</button>
{isEditingContent ? (
<textarea
<CanvasResourceMentionTextarea
ref={textareaRef}
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono outline-none select-text appearance-none"
style={textStyle}
value={node.metadata?.content || ""}
onChange={(event) => onContentChange(node.id, event.target.value)}
references={mentionReferences}
highlightLabels={false}
onChange={(value) => onContentChange(node.id, value)}
onBlur={onStopEditing}
onKeyDown={(event) => {
if (event.key === "Escape") onStopEditing();
@@ -402,8 +431,8 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
/>
) : (
<div
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono leading-relaxed"
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono"
style={textStyle}
onWheel={(event) => event.stopPropagation()}
>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}></span>}
@@ -413,6 +442,14 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
);
}
function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) {
return (
<span className={`pointer-events-none absolute right-2 top-2 z-30 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${reference.active ? "bg-[#2f80ff] text-white shadow-sm" : "bg-black/35 text-white/75"}`}>
{reference.label}
</span>
);
}
function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const content =
@@ -0,0 +1,275 @@
"use client";
import { forwardRef, useMemo, useRef, useState } from "react";
import type { CSSProperties, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
type MentionState = {
start: number;
query: string;
};
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "value"> & {
value: string;
references: CanvasResourceReference[];
onChange: (value: string) => void;
onSubmit?: () => void;
containerClassName?: string;
highlightLabels?: boolean;
};
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea({ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, ...props }, forwardedRef) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [hasSelection, setHasSelection] = useState(false);
const candidates = useMemo(() => {
if (!mention) return [];
const query = mention.query.trim().toLowerCase();
const activeReferences = references.filter((item) => item.active);
if (!query) return activeReferences;
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
}, [mention, references]);
const activeLabels = useMemo(() => (highlightLabels ? Array.from(new Set(references.filter((item) => item.active).map((item) => item.label))).sort((a, b) => b.length - a.length) : []), [highlightLabels, references]);
const updateValue = (next: string, selectionStart?: number) => {
onChange(next);
if (typeof selectionStart !== "number") return;
requestAnimationFrame(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(selectionStart, selectionStart);
});
};
const closeMention = () => {
setMention(null);
setActiveIndex(0);
};
const syncMention = (nextValue: string, cursor: number) => {
const prefix = nextValue.slice(0, cursor);
const match = /(^|\s)@([^\s@]*)$/.exec(prefix);
if (!match || !references.some((item) => item.active)) {
closeMention();
return;
}
setMention({ start: cursor - match[2].length - 1, query: match[2] });
setActiveIndex(0);
};
const insertReference = (reference: CanvasResourceReference) => {
if (!mention) return;
const textarea = textareaRef.current;
const end = textarea?.selectionStart ?? value.length;
const insertText = `${reference.label} `;
const next = `${value.slice(0, mention.start)}${insertText}${value.slice(end)}`;
closeMention();
updateValue(next, mention.start + insertText.length);
};
const syncOverlayScroll = () => {
if (!overlayRef.current || !textareaRef.current) return;
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
overlayRef.current.scrollLeft = textareaRef.current.scrollLeft;
};
const updateSelectionState = () => {
const textarea = textareaRef.current;
setHasSelection(Boolean(textarea && textarea.selectionStart !== textarea.selectionEnd));
};
const showOverlay = Boolean(activeLabels.length && !hasSelection);
const mergedStyle = {
...(style || {}),
color: showOverlay ? "transparent" : style?.color,
caretColor: style?.color || theme.node.text,
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}),
} as CSSProperties;
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
return (
<div className={`relative h-full w-full ${containerClassName || ""}`}>
{showOverlay ? (
<div ref={overlayRef} className={`${className || ""} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`} style={{ ...style, color: theme.node.text }}>
<MentionHighlightText value={value || props.placeholder?.toString() || ""} labels={activeLabels} placeholder={!value} />
</div>
) : null}
<textarea
{...props}
ref={(node) => {
textareaRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
value={value}
className={className}
style={mergedStyle}
onChange={(event) => {
const next = event.target.value;
onChange(next);
syncMention(next, event.target.selectionStart);
requestAnimationFrame(() => {
syncOverlayScroll();
updateSelectionState();
});
}}
onSelect={(event) => {
updateSelectionState();
props.onSelect?.(event);
}}
onKeyUp={(event) => {
updateSelectionState();
props.onKeyUp?.(event);
}}
onPointerUp={(event) => {
updateSelectionState();
props.onPointerUp?.(event);
}}
onKeyDown={(event) => {
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => (index + 1) % candidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMention();
return;
}
}
if (event.key === "Enter" && onSubmit && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
event.preventDefault();
onSubmit();
return;
}
onKeyDown?.(event);
}}
onScroll={(event) => {
syncOverlayScroll();
props.onScroll?.(event);
}}
onBlur={(event) => {
setHasSelection(false);
window.setTimeout(closeMention, 120);
props.onBlur?.(event);
}}
/>
{menu}
</div>
);
});
function MentionHighlightText({ value, labels, placeholder }: { value: string; labels: string[]; placeholder: boolean }) {
if (placeholder) return <span className="opacity-45">{value}</span>;
if (!labels.length) return <>{value}</>;
const pattern = new RegExp(`(${labels.map(escapeRegExp).join("|")})`, "g");
return (
<>
{value.split(pattern).map((part, index) =>
labels.includes(part) ? (
<span key={`${part}-${index}`} className="rounded-md bg-[#2f80ff]/16 px-1 py-0.5 font-medium text-[#2f80ff] ring-1 ring-[#2f80ff]/24">
{part}
</span>
) : (
<span key={`${part}-${index}`}>{part}</span>
),
)}
</>
);
}
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
const selectedRef = useRef(false);
const rect = textarea.getBoundingClientRect();
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
const menuWidth = 256;
const maxMenuHeight = 224;
const gap = 6;
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
event.stopPropagation();
};
const selectReference = (reference: CanvasResourceReference) => {
if (selectedRef.current) return;
selectedRef.current = true;
onSelect(reference);
};
return createPortal(
<div
data-canvas-resource-mention-menu="true"
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={stopCanvasInteraction}
onMouseDown={stopCanvasInteraction}
onClick={(event) => event.stopPropagation()}
>
{references.map((reference, index) => (
<button
key={reference.id}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
selectReference(reference);
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
selectReference(reference);
}}
>
<ReferencePreview reference={reference} />
<span className="min-w-0 flex-1">
<span className="block font-medium">{reference.label}</span>
<span className="block truncate opacity-65">{reference.text || reference.title}</span>
</span>
</button>
))}
</div>,
document.body,
);
}
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
<Icon className="size-4" />
</span>
);
}
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -28,6 +28,7 @@ type CanvasStore = {
openProject: (id: string) => CanvasProject | null;
renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void;
replaceProjects: (projects: CanvasProject[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport">>) => void;
};
@@ -112,6 +113,7 @@ export const useCanvasStore = create<CanvasStore>()(
const projects = state.projects.filter((project) => !ids.includes(project.id));
return { projects };
}),
replaceProjects: (projects) => set({ projects }),
updateProject: (id, patch) =>
set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
+1 -1
View File
@@ -23,6 +23,7 @@ export type CanvasImageGenerationType = "generation" | "edit";
export type CanvasNodeMetadata = {
content?: string;
composerContent?: string;
prompt?: string;
status?: CanvasNodeStatus;
errorDetails?: string;
@@ -51,7 +52,6 @@ export type CanvasNodeMetadata = {
batchUsesReferenceImages?: boolean;
primaryImageId?: string;
imageBatchExpanded?: boolean;
inputOrder?: string[];
storageKey?: string;
mimeType?: string;
bytes?: number;
@@ -14,6 +14,26 @@ export type ImageAngleTransform = {
wideAngle: boolean;
};
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
export const MAX_UPSCALE_LONG_EDGE = 4096;
export type ImageUpscaleParams = {
targetLongEdge: number;
algorithm: ImageUpscaleAlgorithm;
};
export type ImageSplitParams = {
rows: number;
columns: number;
};
export type ImageSplitPiece = {
row: number;
column: number;
dataUrl: string;
};
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
const image = await loadImage(dataUrl);
if (crop) {
@@ -25,6 +45,25 @@ export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
return drawCrop(image, sx, sy, size, size);
}
export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise<ImageSplitPiece[]> {
const image = await loadImage(dataUrl);
const rows = Math.max(1, Math.floor(params.rows));
const columns = Math.max(1, Math.floor(params.columns));
const pieces: ImageSplitPiece[] = [];
for (let row = 0; row < rows; row += 1) {
const sy = Math.floor((row * image.height) / rows);
const sh = Math.floor(((row + 1) * image.height) / rows) - sy;
for (let column = 0; column < columns; column += 1) {
const sx = Math.floor((column * image.width) / columns);
const sw = Math.floor(((column + 1) * image.width) / columns) - sx;
pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) });
}
}
return pieces;
}
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas");
@@ -65,6 +104,19 @@ export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleT
return canvas.toDataURL("image/png");
}
export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) {
const image = await loadImage(dataUrl);
const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge);
return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm);
}
export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) {
const longEdge = Math.max(1, width, height);
const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge)));
const scale = target / longEdge;
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
}
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, sw);
@@ -75,6 +127,39 @@ function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, s
return canvas.toDataURL("image/png");
}
function drawStepUpscale(image: HTMLImageElement, width: number, height: number) {
let source: CanvasImageSource = image;
let sourceWidth = image.width;
let sourceHeight = image.height;
while (sourceWidth * 2 < width && sourceHeight * 2 < height) {
const nextWidth = sourceWidth * 2;
const nextHeight = sourceHeight * 2;
const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high");
source = next;
sourceWidth = nextWidth;
sourceHeight = nextHeight;
}
return drawResize(source, sourceWidth, sourceHeight, width, height, "high");
}
function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png");
}
function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return canvas;
context.imageSmoothingEnabled = algorithm !== "nearest";
context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high";
context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
return canvas;
}
function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve) => {
const image = new Image();
@@ -0,0 +1,98 @@
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
export type CanvasResourceReference = {
id: string;
nodeId: string;
kind: CanvasResourceKind;
label: string;
title: string;
previewUrl?: string;
text?: string;
active: boolean;
};
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
const activeByNodeId = new Map(labelResourceNodes(contextNodes, true).map((reference) => [reference.nodeId, reference]));
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
}
export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
}
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
if (configInputs.length) return configInputs;
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
if (ownInputs.length) return ownInputs;
const node = nodes.find((item) => item.id === nodeId);
return node && isResourceNode(node) ? [node] : [];
}
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
if (configInputs.length) return configInputs;
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
if (ownInputs.length) return ownInputs;
return [];
}
function getContextResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
return connections
.filter((connection) => connection.toNodeId === nodeId)
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node && isResourceNode(node)));
}
function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configConnection = connections.find((connection) => connection.fromNodeId === nodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config);
if (!configConnection) return [];
return getContextResourceNodes(configConnection.toNodeId, nodes, connections).filter((node) => node.id !== nodeId);
}
function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0 };
return nodes.flatMap((node): CanvasResourceReference[] => {
const kind = resourceKind(node);
if (!kind) return [];
const index = counts[kind]++;
const label = labelForKind(kind, index);
return [
{
id: node.id,
nodeId: node.id,
kind,
label,
title: node.title || label,
previewUrl: node.metadata?.content,
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
active,
},
];
});
}
function labelForKind(kind: CanvasResourceKind, index: number) {
if (kind === "image") return imageReferenceLabel(index);
if (kind === "video") return seedanceReferenceLabel("video", index);
if (kind === "audio") return seedanceReferenceLabel("audio", index);
return `文本${index + 1}`;
}
function isResourceNode(node: CanvasNodeData) {
return Boolean(resourceKind(node));
}
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
return null;
}
+78 -24
View File
@@ -16,7 +16,7 @@ import { formatBytes, formatDuration } from "@/lib/image-utils";
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video";
import { useAssetStore } from "@/stores/use-asset-store";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -56,7 +56,8 @@ type GenerationLog = {
size: string;
resolution: string;
seconds: string;
status: "成功" | "失败";
status: "生成中" | "成功" | "失败";
task?: VideoGenerationTask;
video?: GeneratedVideo;
error?: string;
};
@@ -71,6 +72,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function VideoPage() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const activeLogIdsRef = useRef<Set<string>>(new Set());
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
@@ -174,26 +176,15 @@ export default function VideoPage() {
const batchStartedAt = performance.now();
setStartedAt(batchStartedAt);
try {
const stored = await storeGeneratedVideo(await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences));
const nextVideo: GeneratedVideo = {
id: nanoid(),
url: stored.url,
storageKey: stored.storageKey,
durationMs: performance.now() - batchStartedAt,
width: stored.width || 1280,
height: stored.height || 720,
bytes: stored.bytes,
mimeType: stored.mimeType,
};
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
message.success("视频已生成");
const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences);
const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task });
await saveLog(log);
void pollGenerationLog(log, snapshot.config);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
message.error(errorMessage);
} finally {
setRunning(false);
}
};
@@ -276,11 +267,68 @@ export default function VideoPage() {
setDeleteConfirmOpen(false);
};
const saveLog = (log: GenerationLog) => {
void logStore.setItem(log.id, serializeLog(log)).then(refreshLogs);
const saveLog = async (log: GenerationLog) => {
await logStore.setItem(log.id, serializeLog(log));
await refreshLogs();
};
const refreshLogs = async () => setLogs(await readStoredLogs());
const refreshLogs = async () => {
const nextLogs = await readStoredLogs();
setLogs(nextLogs);
resumePendingLogs(nextLogs);
return nextLogs;
};
const resumePendingLogs = (items: GenerationLog[]) => {
for (const log of items) {
if (log.status === "生成中" && log.task) void pollGenerationLog(log);
}
};
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig) => {
if (!log.task || activeLogIdsRef.current.has(log.id)) return;
activeLogIdsRef.current.add(log.id);
setRunning(true);
setStartedAt((value) => value || performance.now());
setResults((value) => (value.length ? value : [{ id: log.id, status: "pending" }]));
const taskConfig = buildVideoConfig({ ...effectiveConfig, ...log.config }, log.task.model || log.model);
try {
for (let attempt = 0; attempt < 120; attempt += 1) {
const state = await pollVideoGenerationTask(configOverride || taskConfig, log.task);
if (state.status === "completed") {
const stored = await storeGeneratedVideo(state.result);
const nextVideo: GeneratedVideo = {
id: nanoid(),
url: stored.url,
storageKey: stored.storageKey,
durationMs: Date.now() - log.createdAt,
width: stored.width || 1280,
height: stored.height || 720,
bytes: stored.bytes,
mimeType: stored.mimeType,
};
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined });
message.success("视频已生成");
return;
}
if (state.status === "failed") throw new Error(state.error);
if (attempt === 119) throw new Error("视频生成超时,请稍后重试");
await delay(log.task.provider === "seedance" ? 5000 : 2500);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: log.id, status: "failed", error: errorMessage }]);
await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage });
message.error(errorMessage);
} finally {
activeLogIdsRef.current.delete(log.id);
if (!activeLogIdsRef.current.size) {
setRunning(false);
setStartedAt(0);
}
}
};
const previewGenerationLog = (log: GenerationLog) => {
setPreviewLog(log);
@@ -295,7 +343,7 @@ export default function VideoPage() {
if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds);
if (log.config.videoGenerateAudio) updateConfig("videoGenerateAudio", log.config.videoGenerateAudio);
if (log.config.videoWatermark) updateConfig("videoWatermark", log.config.videoWatermark);
setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]);
setResults(log.status === "生成中" ? [{ id: log.id, status: "pending" }] : log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]);
};
return (
@@ -608,7 +656,7 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
</div>
</div>
<div className="grid justify-items-end gap-2">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color={log.status === "成功" ? "blue" : "red"}>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color={log.status === "成功" ? "blue" : log.status === "生成中" ? "processing" : "red"}>
{log.status}
</Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
@@ -670,6 +718,7 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
resolution: normalizeResolution(log.resolution || config.vquality || ""),
seconds: log.seconds || config.videoSeconds || "",
status: log.status || "成功",
task: log.task,
video,
error: log.error,
};
@@ -739,7 +788,7 @@ function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
};
}
function buildLog({ prompt, model, config, references, videoReferences, audioReferences, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; videoReferences: ReferenceVideo[]; audioReferences: ReferenceAudio[]; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog {
function buildLog({ prompt, model, config, references, videoReferences, audioReferences, durationMs, status, task, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; videoReferences: ReferenceVideo[]; audioReferences: ReferenceAudio[]; durationMs: number; status: GenerationLog["status"]; task?: VideoGenerationTask; video?: GeneratedVideo; error?: string }): GenerationLog {
const logConfig = {
model: config.model,
videoModel: config.videoModel,
@@ -765,6 +814,7 @@ function buildLog({ prompt, model, config, references, videoReferences, audioRef
resolution: logConfig.vquality,
seconds: logConfig.videoSeconds,
status,
task,
video,
error,
};
@@ -797,3 +847,7 @@ function normalizeVideoSize(value: string) {
function normalizeResolution(value: string) {
return normalizeVideoResolutionValue(value);
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const WEBDAV_PROXY_TIMEOUT_MS = 120000;
export async function POST(request: NextRequest) {
const target = request.headers.get("x-webdav-target") || "";
const method = (request.headers.get("x-webdav-method") || "GET").toUpperCase();
if (!target) return new Response("Missing x-webdav-target", { status: 400 });
let url: URL;
try {
url = new URL(target);
} catch {
return new Response("Invalid x-webdav-target", { status: 400 });
}
if (url.protocol !== "http:" && url.protocol !== "https:") return new Response("Unsupported WebDAV target", { status: 400 });
const headers = new Headers();
copyHeader(request, headers, "x-webdav-authorization", "Authorization");
copyHeader(request, headers, "x-webdav-depth", "Depth");
copyHeader(request, headers, "x-webdav-destination", "Destination");
copyHeader(request, headers, "x-webdav-overwrite", "Overwrite");
copyHeader(request, headers, "x-webdav-content-type", "Content-Type");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WEBDAV_PROXY_TIMEOUT_MS);
try {
const body = method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer();
console.log(`[webdav-proxy] ${method} ${url.href} ${body?.byteLength || 0}B`);
const response = await fetch(url, { method, headers, body: body?.byteLength ? body : undefined, signal: controller.signal });
console.log(`[webdav-proxy] ${method} ${url.href} -> ${response.status}`);
return new Response(method === "HEAD" ? null : response.body, {
status: response.status,
headers: responseHeaders(response.headers),
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return new Response("WebDAV proxy timeout", { status: 504 });
return new Response(error instanceof Error ? error.message : "WebDAV proxy error", { status: 502 });
} finally {
clearTimeout(timer);
}
}
function copyHeader(request: NextRequest, headers: Headers, from: string, to: string) {
const value = request.headers.get(from);
if (value) headers.set(to, value);
}
function responseHeaders(headers: Headers) {
const result = new Headers();
["content-type", "etag", "last-modified", "dav"].forEach((key) => {
const value = headers.get(key);
if (value) result.set(key, value);
});
return result;
}
+184 -1
View File
@@ -1,10 +1,13 @@
"use client";
import { App, Button, Form, Input, Modal, Segmented, Select } from "antd";
import { App, Button, Form, Input, Modal, Progress, Segmented, Select } from "antd";
import { Cloud, RefreshCw, Wifi } from "lucide-react";
import { useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { fetchImageModels } from "@/services/api/image";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
@@ -16,6 +19,14 @@ type ModelGroup = {
optionsLabel: string;
};
type WebdavDomainProgress = {
label: string;
stage: string;
current?: number;
total?: number;
status?: "active" | "success" | "exception";
};
const modelGroups: ModelGroup[] = [
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
@@ -23,11 +34,35 @@ const modelGroups: ModelGroup[] = [
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
];
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
canvas: "画布",
assets: "我的素材",
"image-workbench": "生图工作台",
"video-workbench": "视频创作台",
};
function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProgress> {
return webdavDomainKeys.reduce(
(progress, key) => ({
...progress,
[key]: { label: webdavDomainLabels[key], stage: "等待同步" },
}),
{} as Record<AppSyncDomainKey, WebdavDomainProgress>,
);
}
export function AppConfigModal() {
const { message } = App.useApp();
const [loadingModels, setLoadingModels] = useState(false);
const [testingWebdav, setTestingWebdav] = useState(false);
const [syncingWebdav, setSyncingWebdav] = useState(false);
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
const [webdavDomainProgress, setWebdavDomainProgress] = useState(createWebdavDomainProgress);
const config = useConfigStore((state) => state.config);
const webdav = useConfigStore((state) => state.webdav);
const updateConfig = useConfigStore((state) => state.updateConfig);
const updateWebdavConfig = useConfigStore((state) => state.updateWebdavConfig);
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
@@ -39,6 +74,7 @@ export function AppConfigModal() {
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
const modelOptions = config.models.map((model) => ({ label: model, value: model }));
const webdavReady = Boolean(webdav.url.trim());
const finishConfig = () => {
setConfigDialogOpen(false);
@@ -89,6 +125,57 @@ export function AppConfigModal() {
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
};
const testWebdav = async () => {
if (!webdavReady) {
message.error("请先填写 WebDAV 地址");
return;
}
setTestingWebdav(true);
try {
await testWebdavConnection(webdav);
message.success("WebDAV 连接可用");
} catch (error) {
message.error(error instanceof Error ? error.message : "WebDAV 连接测试失败");
} finally {
setTestingWebdav(false);
}
};
const updateWebdavProgress = (event: AppSyncProgressEvent) => {
setWebdavSyncStatus(event.stage);
if (!event.domain) return;
setWebdavDomainProgress((current) => ({
...current,
[event.domain as AppSyncDomainKey]: {
label: event.label || webdavDomainLabels[event.domain as AppSyncDomainKey],
stage: event.stage,
current: event.current,
total: event.total,
status: event.status,
},
}));
};
const syncWebdav = async () => {
if (!webdavReady) {
message.error("请先填写 WebDAV 地址");
return;
}
setSyncingWebdav(true);
setWebdavDomainProgress(createWebdavDomainProgress());
setWebdavSyncStatus("准备同步");
try {
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
updateWebdavConfig("lastSyncedAt", result.syncedAt);
message.success(`同步完成:${result.projects} 个画布,${result.assets} 个素材,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
} catch (error) {
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
} finally {
setSyncingWebdav(false);
}
};
return (
<Modal
title={
@@ -218,6 +305,72 @@ export function AppConfigModal() {
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Cloud className="size-4" />
WebDAV
</div>
<div className="mt-1 text-xs text-stone-500"> AI API Key CORS Next.js </div>
</div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="连接方式" className="mb-4 md:col-span-2">
<Segmented
block
value={webdav.proxyMode}
onChange={(value) => updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)}
options={[
{ label: "前端直连", value: "direct" },
{ label: "Next.js 转发", value: "nextjs" },
]}
/>
</Form.Item>
<Form.Item label="WebDAV 地址" className="mb-4">
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
</Form.Item>
<Form.Item label="远程目录" extra={`会在该目录下分业务目录保存,每个目录包含 ${WEBDAV_MANIFEST_FILE_NAME} 和 files/`} className="mb-4">
<Input value={webdav.directory} placeholder="infinite-canvas" onChange={(event) => updateWebdavConfig("directory", event.target.value)} />
</Form.Item>
<Form.Item label="用户名" className="mb-0">
<Input value={webdav.username} autoComplete="username" onChange={(event) => updateWebdavConfig("username", event.target.value)} />
</Form.Item>
<Form.Item label="密码 / 应用密码" className="mb-0">
<Input.Password value={webdav.password} autoComplete="current-password" onChange={(event) => updateWebdavConfig("password", event.target.value)} />
</Form.Item>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button icon={<Wifi className="size-4" />} disabled={!webdavReady || syncingWebdav} loading={testingWebdav} onClick={() => void testWebdav()}>
</Button>
<Button type="primary" icon={<RefreshCw className="size-4" />} disabled={!webdavReady || testingWebdav} loading={syncingWebdav} onClick={() => void syncWebdav()}>
{syncingWebdav ? "同步中" : "立即同步"}
</Button>
{webdavSyncStatus ? <span className="text-xs text-stone-500">{webdavSyncStatus}</span> : null}
</div>
{syncingWebdav || webdavSyncStatus ? (
<div className="mt-3 grid gap-2">
{webdavDomainKeys.map((key) => {
const item = webdavDomainProgress[key];
const count = item.total ? `${item.current || 0}/${item.total}` : "";
return (
<div key={key} className="rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="mb-1 flex min-w-0 items-center justify-between gap-3 text-xs">
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{item.label}</span>
<span className="min-w-0 truncate text-right text-stone-500">
{item.stage}
{count ? ` · ${count}` : ""}
</span>
</div>
<Progress percent={getWebdavProgressPercent(item)} size="small" status={getWebdavProgressStatus(item)} showInfo={false} />
</div>
);
})}
</div>
) : null}
</section>
</Form>
</div>
</Modal>
@@ -237,3 +390,33 @@ function resolveNextCapabilityModels(current: string[], suggested: string[], all
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function formatWebdavTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function getWebdavProgressPercent(item: WebdavDomainProgress) {
if (item.status === "success") return 100;
if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100));
if (item.status === "exception") return 100;
if (item.stage === "等待同步") return 0;
if (item.stage === "读取远端清单") return 12;
if (item.stage === "读取本地数据") return 24;
if (item.stage === "下载缺失媒体") return 36;
if (item.stage === "写入本地合并结果") return 58;
if (item.stage === "上传新增媒体") return 66;
if (item.stage === "媒体已齐全" || item.stage === "媒体无需上传") return 74;
if (item.stage.startsWith("上传清单")) return 90;
return item.status === "active" ? 30 : 0;
}
function getWebdavProgressStatus(item: WebdavDomainProgress): "normal" | "active" | "success" | "exception" {
if (item.status === "success" || item.status === "exception") return item.status;
return item.status === "active" ? "active" : "normal";
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
+2 -1
View File
@@ -222,7 +222,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
}
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[]) {
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage) {
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
@@ -241,6 +241,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
}
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => formData.append("image", file));
if (mask) formData.set("mask", dataUrlToFile(mask));
try {
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
+60 -30
View File
@@ -21,6 +21,8 @@ type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
type ReferenceMediaUploadResponse = { id: string; url: string; mimeType: string; bytes: number };
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
@@ -44,15 +46,33 @@ function refreshRemoteUser(config: AiConfig) {
}
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise<VideoGenerationResult> {
const task = await createVideoGenerationTask(config, prompt, references, videoReferences, audioReferences);
const delayMs = task.provider === "seedance" ? 5000 : 2500;
for (let attempt = 0; attempt < 120; attempt += 1) {
const state = await pollVideoGenerationTask(config, task);
if (state.status === "completed") return state.result;
if (state.status === "failed") throw new Error(state.error);
if (attempt === 119) throw new Error(`${task.provider === "seedance" ? "Seedance " : ""}视频生成超时,请稍后重试`);
await delay(delayMs);
}
throw new Error("视频生成超时,请稍后重试");
}
export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise<VideoGenerationTask> {
const model = (config.model || config.videoModel).trim();
assertVideoConfig(config, model);
if (isSeedanceVideoConfig({ ...config, model })) {
return requestSeedanceGeneration(config, model, prompt, references, videoReferences, audioReferences);
return createSeedanceTask(config, model, prompt, references, videoReferences, audioReferences);
}
if (videoReferences.length || audioReferences.length) {
throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材");
}
return requestOpenAIVideoGeneration(config, model, prompt, references);
return createOpenAIVideoTask(config, model, prompt, references);
}
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
assertVideoConfig(config, task.model);
return task.provider === "seedance" ? pollSeedanceTask(config, task) : pollOpenAIVideoTask(config, task);
}
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
@@ -61,7 +81,7 @@ export async function storeGeneratedVideo(result: VideoGenerationResult): Promis
throw new Error("视频接口没有返回可播放的视频");
}
async function requestOpenAIVideoGeneration(config: AiConfig, model: string, prompt: string, references: ReferenceImage[]) {
async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[]): Promise<VideoGenerationTask> {
const body = new FormData();
body.append("model", model);
body.append("prompt", prompt);
@@ -74,23 +94,29 @@ async function requestOpenAIVideoGeneration(config: AiConfig, model: string, pro
try {
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config) })).data);
if (!created.id) throw new Error("视频接口没有返回任务 ID");
for (let attempt = 0; attempt < 120; attempt += 1) {
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${created.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined })).data);
if (video.status === "completed") break;
if (video.status === "failed" || video.status === "cancelled") throw new Error(video.error?.message || "视频生成失败");
if (attempt === 119) throw new Error("视频生成超时,请稍后重试");
await delay(2500);
}
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${created.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" });
await assertVideoBlob(content.data);
refreshRemoteUser(config);
return { blob: content.data };
return { id: created.id, provider: "openai", model };
} catch (error) {
throw new Error(readAxiosError(error, "视频生成失败"));
throw new Error(readAxiosError(error, "视频任务创建失败"));
}
}
async function requestSeedanceGeneration(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
try {
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
if (video.status === "completed") {
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined, responseType: "blob" });
await assertVideoBlob(content.data);
refreshRemoteUser(config);
return { status: "completed", result: { blob: content.data } };
}
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, "视频任务查询失败"));
}
}
async function createSeedanceTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]): Promise<VideoGenerationTask> {
if (audioReferences.length && !references.length && !videoReferences.length) {
throw new Error("Seedance 参考音频不能单独使用,请同时添加参考图或参考视频");
}
@@ -111,21 +137,25 @@ async function requestSeedanceGeneration(config: AiConfig, model: string, prompt
try {
const created = unwrapSeedanceTask((await axios.post<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json") })).data);
if (!created.id) throw new Error("Seedance 接口没有返回任务 ID");
for (let attempt = 0; attempt < 120; attempt += 1) {
const task = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, created.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined })).data);
if (task.status === "succeeded") {
const url = task.content?.video_url;
if (!url) throw new Error("Seedance 任务成功但没有返回视频 URL");
refreshRemoteUser(config);
return videoResultFromUrl(url);
}
if (task.status === "failed" || task.status === "cancelled" || task.status === "expired") throw new Error(task.error?.message || `Seedance 视频生成${task.status === "expired" ? "超时" : "失败"}`);
if (attempt === 119) throw new Error("Seedance 视频生成超时,请稍后重试");
await delay(5000);
}
throw new Error("Seedance 视频生成超时,请稍后重试");
return { id: created.id, provider: "seedance", model };
} catch (error) {
throw new Error(readAxiosError(error, "Seedance 视频生成失败"));
throw new Error(readAxiosError(error, "Seedance 任务创建失败"));
}
}
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
try {
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
if (state.status === "succeeded") {
const url = state.content?.video_url;
if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
refreshRemoteUser(config);
return { status: "completed", result: await videoResultFromUrl(url) };
}
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, "Seedance 任务查询失败"));
}
}
+394
View File
@@ -0,0 +1,394 @@
"use client";
import localforage from "localforage";
import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage";
import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage";
import { downloadWebdavFile, uploadWebdavFile, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import type { Asset } from "@/stores/use-asset-store";
import { useAssetStore } from "@/stores/use-asset-store";
import type { WebdavSyncConfig } from "@/stores/use-config-store";
import type { CanvasProject } from "@/app/(user)/canvas/stores/use-canvas-store";
import { useCanvasStore } from "@/app/(user)/canvas/stores/use-canvas-store";
type StoredLog = Record<string, unknown> & { id?: string };
export type AppSyncDomainKey = "canvas" | "assets" | "image-workbench" | "video-workbench";
type DomainKey = AppSyncDomainKey;
type CanvasDomainData = { projects: CanvasProject[] };
type AssetDomainData = { assets: Asset[] };
type LogDomainData = { logs: StoredLog[] };
type AppSyncFile = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
type DomainManifest<T> = {
app: "infinite-canvas";
version: 1;
domain: DomainKey;
exportedAt: string;
data: T;
files: AppSyncFile[];
};
type SyncDomainOptions<T> = {
key: DomainKey;
label: string;
localData: () => Promise<T>;
emptyData: T;
mergeData: (local: T, remote: T) => T;
applyData?: (data: T) => Promise<void>;
};
type SyncDomainResult<T> = {
data: T;
mergedRemote: boolean;
files: number;
manifestBytes: number;
uploadedFiles: number;
uploadedBytes: number;
};
export type AppSyncResult = {
syncedAt: string;
mergedRemote: boolean;
projects: number;
assets: number;
imageLogs: number;
videoLogs: number;
files: number;
manifestBytes: number;
uploadedFiles: number;
uploadedBytes: number;
};
export type AppSyncProgressEvent = {
domain?: AppSyncDomainKey;
label?: string;
stage: string;
current?: number;
total?: number;
status?: "active" | "success" | "exception";
};
export type AppSyncProgress = (event: AppSyncProgressEvent) => void;
const FILE_CONCURRENCY = 3;
const imageLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
const videoLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" });
type LogStore = typeof imageLogStore;
const storageKeyPattern = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncAppDataToWebdav(config: WebdavSyncConfig, onProgress?: AppSyncProgress): Promise<AppSyncResult> {
emitProgress(onProgress, { stage: "等待本地数据加载" });
await Promise.all([waitForHydration(useCanvasStore), waitForHydration(useAssetStore)]);
const [canvas, assets, imageLogs, videoLogs] = await Promise.all([
syncDomain<CanvasDomainData>(config, onProgress, {
key: "canvas",
label: "画布",
emptyData: { projects: [] },
localData: async () => ({ projects: useCanvasStore.getState().projects }),
mergeData: (local, remote) => ({ projects: mergeById(local.projects, remote.projects, "updatedAt") }),
applyData: async (data) => useCanvasStore.getState().replaceProjects(data.projects),
}),
syncDomain<AssetDomainData>(config, onProgress, {
key: "assets",
label: "我的素材",
emptyData: { assets: [] },
localData: async () => ({ assets: useAssetStore.getState().assets }),
mergeData: (local, remote) => ({ assets: mergeById(local.assets, remote.assets, "updatedAt") }),
applyData: async (data) => useAssetStore.getState().replaceAssets(await Promise.all(data.assets.map(hydrateAsset))),
}),
syncDomain<LogDomainData>(config, onProgress, {
key: "image-workbench",
label: "生图工作台",
emptyData: { logs: [] },
localData: async () => ({ logs: await readStoredLogs(imageLogStore) }),
mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }),
applyData: async (data) => replaceStoredLogs(imageLogStore, data.logs),
}),
syncDomain<LogDomainData>(config, onProgress, {
key: "video-workbench",
label: "视频创作台",
emptyData: { logs: [] },
localData: async () => ({ logs: await readStoredLogs(videoLogStore) }),
mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }),
applyData: async (data) => replaceStoredLogs(videoLogStore, data.logs),
}),
]);
const result = {
syncedAt: new Date().toISOString(),
mergedRemote: [canvas, assets, imageLogs, videoLogs].some((item) => item.mergedRemote),
projects: canvas.data.projects.length,
assets: assets.data.assets.length,
imageLogs: imageLogs.data.logs.length,
videoLogs: videoLogs.data.logs.length,
files: canvas.files + assets.files + imageLogs.files + videoLogs.files,
manifestBytes: canvas.manifestBytes + assets.manifestBytes + imageLogs.manifestBytes + videoLogs.manifestBytes,
uploadedFiles: canvas.uploadedFiles + assets.uploadedFiles + imageLogs.uploadedFiles + videoLogs.uploadedFiles,
uploadedBytes: canvas.uploadedBytes + assets.uploadedBytes + imageLogs.uploadedBytes + videoLogs.uploadedBytes,
};
emitProgress(onProgress, { stage: "同步完成", status: "success" });
return result;
}
async function syncDomain<T>(config: WebdavSyncConfig, onProgress: AppSyncProgress | undefined, options: SyncDomainOptions<T>): Promise<SyncDomainResult<T>> {
try {
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取远端清单", status: "active" });
const remoteManifest = await readDomainManifest(config, options.key, options.emptyData);
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取本地数据", status: "active" });
const localData = await options.localData();
const mergedData = remoteManifest ? options.mergeData(localData, remoteManifest.data) : localData;
if (remoteManifest) {
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "下载缺失媒体", status: "active" });
await downloadMissingFiles(config, options.key, mergedData, remoteManifest.files, onProgress);
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "写入本地合并结果", status: "active" });
await options.applyData?.(mergedData);
}
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "上传新增媒体", status: "active" });
const uploaded = await uploadChangedFiles(config, options.key, mergedData, remoteManifest?.files || [], onProgress);
const manifest: DomainManifest<T> = { app: "infinite-canvas", version: 1, domain: options.key, exportedAt: new Date().toISOString(), data: mergedData, files: uploaded.files };
const manifestFile = new Blob([JSON.stringify(manifest, null, 2)], { type: "application/json" });
emitProgress(onProgress, { domain: options.key, label: options.label, stage: `上传清单 ${formatBytes(manifestFile.size)}`, status: "active" });
await uploadWebdavFile(config, domainPath(options.key, WEBDAV_MANIFEST_FILE_NAME), manifestFile, "application/json");
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "完成", current: 1, total: 1, status: "success" });
return {
data: mergedData,
mergedRemote: Boolean(remoteManifest),
files: uploaded.files.length,
manifestBytes: manifestFile.size,
uploadedFiles: uploaded.uploadedFiles,
uploadedBytes: uploaded.uploadedBytes,
};
} catch (error) {
emitProgress(onProgress, { domain: options.key, label: options.label, stage: error instanceof Error ? error.message : "同步失败", status: "exception" });
throw error;
}
}
async function readDomainManifest<T>(config: WebdavSyncConfig, domain: DomainKey, emptyData: T): Promise<DomainManifest<T> | null> {
const file = await downloadWebdavFile(config, domainPath(domain, WEBDAV_MANIFEST_FILE_NAME));
if (!file) return null;
const data = JSON.parse(await file.text()) as DomainManifest<T>;
if (data.app !== "infinite-canvas" || data.domain !== domain) throw new Error(`${domain} 同步清单不是当前应用的数据`);
return {
app: "infinite-canvas",
version: 1,
domain,
exportedAt: data.exportedAt || new Date().toISOString(),
data: data.data || emptyData,
files: Array.isArray(data.files) ? data.files : [],
};
}
async function downloadMissingFiles<T>(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) {
const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item]));
const tasks: AppSyncFile[] = [];
const storageKeys = collectStorageKeys(data);
let scanned = 0;
for (const storageKey of storageKeys) {
const localBlob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
scanned += 1;
if (localBlob) {
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" });
continue;
}
const remoteFile = remoteFileMap.get(storageKey);
if (remoteFile) tasks.push(remoteFile);
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" });
}
if (!tasks.length) {
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体已齐全", current: 1, total: 1, status: "active" });
return;
}
let downloaded = 0;
await runWithConcurrency(tasks, FILE_CONCURRENCY, async (remoteFile) => {
const blob = await downloadWebdavFile(config, remoteFile.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, remoteFile.mimeType);
await (remoteFile.storageKey.startsWith("image:") ? setImageBlob(remoteFile.storageKey, typedBlob) : setMediaBlob(remoteFile.storageKey, typedBlob));
downloaded += 1;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "下载媒体", current: downloaded, total: tasks.length, status: "active" });
});
}
async function uploadChangedFiles<T>(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) {
const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item]));
const files: AppSyncFile[] = [];
const tasks: Array<{ item: AppSyncFile; blob: Blob }> = [];
let uploadedFiles = 0;
let uploadedBytes = 0;
const storageKeys = collectStorageKeys(data);
let scanned = 0;
for (const storageKey of storageKeys) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
const remoteFile = remoteFileMap.get(storageKey);
if (!blob) {
if (remoteFile) files.push(remoteFile);
scanned += 1;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" });
continue;
}
const item: AppSyncFile = {
storageKey,
path: remoteFile?.path || domainPath(domain, `files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`),
mimeType: blob.type || remoteFile?.mimeType || "application/octet-stream",
bytes: blob.size,
};
files.push(item);
if (!remoteFile || remoteFile.bytes !== blob.size) tasks.push({ item, blob });
scanned += 1;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" });
}
if (!tasks.length) {
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体无需上传", current: 1, total: 1, status: "active" });
return { files, uploadedFiles, uploadedBytes };
}
await runWithConcurrency(tasks, FILE_CONCURRENCY, async ({ item, blob }) => {
await uploadWebdavFile(config, item.path, blob, item.mimeType);
uploadedFiles += 1;
uploadedBytes += blob.size;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: `上传媒体 ${formatBytes(blob.size)}`, current: uploadedFiles, total: tasks.length, status: "active" });
});
return { files, uploadedFiles, uploadedBytes };
}
async function hydrateAsset(asset: Asset): Promise<Asset> {
if (asset.kind === "image" && asset.data.storageKey) {
const dataUrl = await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl);
return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? dataUrl : asset.coverUrl, data: { ...asset.data, dataUrl } };
}
if (asset.kind === "video" && asset.data.storageKey) {
const url = await resolveMediaUrl(asset.data.storageKey, asset.data.url);
return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? url : asset.coverUrl, data: { ...asset.data, url } };
}
return asset;
}
async function readStoredLogs(store: LogStore) {
const logs: StoredLog[] = [];
await store.iterate<StoredLog, void>((value) => {
if (value && typeof value === "object") logs.push(value);
});
return logs;
}
async function replaceStoredLogs(store: LogStore, logs: StoredLog[]) {
await store.clear();
await runWithConcurrency(logs, FILE_CONCURRENCY, async (log) => {
const id = getStringField(log, "id");
if (id) await store.setItem(id, log);
});
}
function mergeById<T extends { id?: string }>(local: T[], remote: T[], timeKey: string) {
const items = new Map<string, T>();
remote.forEach((item) => {
const id = item.id || "";
if (id) items.set(id, item);
});
local.forEach((item) => {
const id = item.id || "";
if (!id) return;
const current = items.get(id);
if (!current || getTime(item as Record<string, unknown>, timeKey) >= getTime(current as Record<string, unknown>, timeKey)) items.set(id, item);
});
return Array.from(items.values()).sort((a, b) => getTime(b as Record<string, unknown>, timeKey) - getTime(a as Record<string, unknown>, timeKey));
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (typeof value === "string") {
if (storageKeyPattern.test(value)) keys.add(value);
return [...keys];
}
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && storageKeyPattern.test(value.storageKey)) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
return [...keys];
}
function domainPath(domain: DomainKey, path: string) {
return `${domain}/${path}`;
}
function domainLabel(domain: DomainKey) {
if (domain === "canvas") return "画布";
if (domain === "assets") return "我的素材";
if (domain === "image-workbench") return "生图工作台";
return "视频创作台";
}
function emitProgress(onProgress: AppSyncProgress | undefined, event: AppSyncProgressEvent) {
onProgress?.(event);
}
function getStringField(item: Record<string, unknown>, key: string) {
const value = item[key];
return typeof value === "string" ? value : "";
}
function getTime(item: Record<string, unknown>, key: string) {
const value = item[key];
if (typeof value === "number") return value;
if (typeof value === "string") return Date.parse(value) || 0;
return 0;
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("wav")) return "wav";
if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3";
return storageKey.startsWith("image:") ? "png" : "bin";
}
function waitForHydration<T extends { hydrated: boolean }>(store: { getState: () => T; subscribe: (listener: (state: T) => void) => () => void }) {
if (store.getState().hydrated) return Promise.resolve();
return new Promise<void>((resolve) => {
const unsubscribe = store.subscribe((state) => {
if (!state.hydrated) return;
unsubscribe();
resolve();
});
});
}
async function runWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>) {
const results = new Array<R>(items.length);
let nextIndex = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await worker(items[index], index);
}
}),
);
return results;
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import type { WebdavSyncConfig } from "@/stores/use-config-store";
export const WEBDAV_MANIFEST_FILE_NAME = "manifest.json";
const WEBDAV_REQUEST_TIMEOUT_MS = 120000;
const ensuredDirectories = new Set<string>();
export async function testWebdavConnection(config: WebdavSyncConfig) {
await ensureWebdavDirectory(config);
const response = await webdavFetch(config, "", { method: "PROPFIND", headers: { Depth: "0" } });
if (response.ok || response.status === 207) return;
await throwWebdavError(response, "WebDAV 连接测试失败");
}
export async function downloadWebdavSyncFile(config: WebdavSyncConfig) {
return downloadWebdavFile(config, WEBDAV_MANIFEST_FILE_NAME);
}
export async function downloadWebdavFile(config: WebdavSyncConfig, path: string) {
await ensureWebdavDirectory(config);
const response = await webdavFetch(config, path, { method: "GET" });
if (response.status === 404) return null;
if (!response.ok) await throwWebdavError(response, "读取 WebDAV 同步文件失败");
const file = await withTimeout(response.blob(), "读取 WebDAV 同步文件超时");
return file.size ? file : null;
}
export async function uploadWebdavSyncFile(config: WebdavSyncConfig, file: Blob) {
return uploadWebdavFile(config, WEBDAV_MANIFEST_FILE_NAME, file, "application/json");
}
export async function uploadWebdavFile(config: WebdavSyncConfig, path: string, file: Blob, contentType = "application/octet-stream") {
if (!file.size) throw new Error("上传文件为空,已取消上传");
await ensureWebdavDirectory(config);
await ensureWebdavSubdirectory(config, path);
const response = await webdavFetch(config, path, {
method: "PUT",
headers: { "Content-Type": contentType },
body: file,
});
if (!response.ok) await throwWebdavError(response, "上传 WebDAV 同步文件失败");
}
async function ensureWebdavDirectory(config: WebdavSyncConfig) {
assertWebdavConfig(config);
await ensureWebdavDirectoryPath(config, config.directory);
}
async function ensureWebdavSubdirectory(config: WebdavSyncConfig, path: string) {
const directory = normalizePath(path).split("/").slice(0, -1).join("/");
if (!directory) return;
await ensureWebdavDirectoryPath(config, [config.directory, directory].filter(Boolean).join("/"));
}
async function ensureWebdavDirectoryPath(config: WebdavSyncConfig, directory: string) {
const parts = normalizePath(directory).split("/").filter(Boolean);
const cacheKey = `${config.proxyMode}:${config.url}:${parts.join("/")}`;
if (ensuredDirectories.has(cacheKey)) return;
let path = "";
for (const part of parts) {
path = path ? `${path}/${part}` : part;
const response = await webdavFetch({ ...config, directory: "" }, path, { method: "MKCOL" });
if (response.ok || ((response.status === 405 || response.status === 423) && (await webdavDirectoryExists(config, path)))) continue;
await throwWebdavError(response, "创建 WebDAV 远程目录失败");
}
ensuredDirectories.add(cacheKey);
}
async function webdavDirectoryExists(config: WebdavSyncConfig, path: string) {
const response = await webdavFetch({ ...config, directory: "" }, path, { method: "PROPFIND", headers: { Depth: "0" } });
return response.ok || response.status === 207;
}
async function webdavFetch(config: WebdavSyncConfig, path: string, init: RequestInit) {
const headers = new Headers(init.headers);
if (config.username || config.password) headers.set("Authorization", `Basic ${encodeBasicAuth(`${config.username}:${config.password}`)}`);
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), WEBDAV_REQUEST_TIMEOUT_MS);
try {
const url = buildWebdavUrl(config, path);
if (config.proxyMode === "nextjs") return await fetch("/webdav-proxy", { method: "POST", headers: proxyHeaders(url, init.method || "GET", headers), body: proxyBody(init), signal: controller.signal });
return await fetch(url, { ...init, headers, signal: controller.signal });
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error("WebDAV 请求超时,请检查网络、代理或远端服务状态");
if (error instanceof TypeError) throw new Error("无法连接 WebDAV,请检查地址、HTTPS 证书、CORS 或网络状态");
throw error;
} finally {
window.clearTimeout(timer);
}
}
function proxyHeaders(target: string, method: string, headers: Headers) {
const proxyHeaders = new Headers({
"x-webdav-target": target,
"x-webdav-method": method,
});
copyProxyHeader(headers, proxyHeaders, "Authorization", "x-webdav-authorization");
copyProxyHeader(headers, proxyHeaders, "Depth", "x-webdav-depth");
copyProxyHeader(headers, proxyHeaders, "Destination", "x-webdav-destination");
copyProxyHeader(headers, proxyHeaders, "Overwrite", "x-webdav-overwrite");
copyProxyHeader(headers, proxyHeaders, "Content-Type", "x-webdav-content-type");
const contentType = headers.get("Content-Type");
if (contentType) proxyHeaders.set("Content-Type", contentType);
return proxyHeaders;
}
function copyProxyHeader(from: Headers, to: Headers, source: string, target: string) {
const value = from.get(source);
if (value) to.set(target, value);
}
function proxyBody(init: RequestInit) {
const method = (init.method || "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return undefined;
return init.body || undefined;
}
function buildWebdavUrl(config: WebdavSyncConfig, path: string) {
const baseUrl = config.url.trim().replace(/\/+$/, "");
const remotePath = [normalizePath(config.directory), normalizePath(path)].filter(Boolean).join("/");
if (!remotePath) return baseUrl;
return `${baseUrl}/${remotePath.split("/").map(encodeURIComponent).join("/")}`;
}
function normalizePath(path: string) {
return path.trim().replace(/^\/+|\/+$/g, "");
}
function assertWebdavConfig(config: WebdavSyncConfig) {
if (!config.url.trim()) throw new Error("请先填写 WebDAV 地址");
}
async function throwWebdavError(response: Response, fallback: string): Promise<never> {
const detail = await response.text().catch(() => "");
if (response.status === 401 || response.status === 403) throw new Error("WebDAV 认证失败,请检查用户名、密码或应用密码");
if (response.status === 404) throw new Error("WebDAV 路径不存在,请检查地址和远程目录");
throw new Error(`${fallback}${response.status}${detail ? ` ${detail.slice(0, 120)}` : ""}`);
}
function encodeBasicAuth(value: string) {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
function withTimeout<T>(promise: Promise<T>, message: string) {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), WEBDAV_REQUEST_TIMEOUT_MS);
promise.then(resolve, reject).finally(() => window.clearTimeout(timer));
});
}
+7
View File
@@ -28,10 +28,12 @@ type AssetBase<T extends AssetKind> = {
};
type AssetStore = {
hydrated: boolean;
assets: Asset[];
addAsset: (asset: Omit<Asset, "id" | "createdAt" | "updatedAt">) => string;
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
removeAsset: (id: string) => void;
replaceAssets: (assets: Asset[]) => void;
cleanupImages: (extra?: unknown) => void;
};
@@ -66,6 +68,7 @@ const assetStorage: PersistStorage<AssetStore> = {
export const useAssetStore = create<AssetStore>()(
persist(
(set, get) => ({
hydrated: false,
assets: [],
addAsset: (asset) => {
const now = new Date().toISOString();
@@ -83,6 +86,7 @@ export const useAssetStore = create<AssetStore>()(
get().cleanupImages({ assets });
return { assets };
}),
replaceAssets: (assets) => set({ assets }),
cleanupImages: (extra) => {
window.setTimeout(async () => {
const { useCanvasStore } = await import("@/app/(user)/canvas/stores/use-canvas-store");
@@ -95,6 +99,9 @@ export const useAssetStore = create<AssetStore>()(
name: ASSET_STORE_KEY,
storage: assetStorage,
partialize: (state) => ({ assets: state.assets }) as StorageValue<AssetStore>["state"],
onRehydrateStorage: () => () => {
useAssetStore.setState({ hydrated: true });
},
},
),
);
+33 -2
View File
@@ -36,6 +36,15 @@ export type AiConfig = {
canvasImageCount: string;
};
export type WebdavSyncConfig = {
proxyMode: "direct" | "nextjs";
url: string;
username: string;
password: string;
directory: string;
lastSyncedAt: string;
};
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
export type ModelCapability = "image" | "video" | "text" | "audio";
@@ -68,13 +77,24 @@ export const defaultConfig: AiConfig = {
canvasImageCount: "3",
};
export const defaultWebdavSyncConfig: WebdavSyncConfig = {
proxyMode: "direct",
url: "",
username: "",
password: "",
directory: "infinite-canvas",
lastSyncedAt: "",
};
type ConfigStore = {
config: AiConfig;
webdav: WebdavSyncConfig;
publicSettings: AdminPublicSettings | null;
isPublicSettingsLoading: boolean;
isConfigOpen: boolean;
shouldPromptContinue: boolean;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
updateWebdavConfig: <K extends keyof WebdavSyncConfig>(key: K, value: WebdavSyncConfig[K]) => void;
loadPublicSettings: () => Promise<void>;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
openConfigDialog: (shouldPromptContinue?: boolean) => void;
@@ -168,6 +188,7 @@ export const useConfigStore = create<ConfigStore>()(
persist(
(set, get) => ({
config: defaultConfig,
webdav: defaultWebdavSyncConfig,
publicSettings: null,
isPublicSettingsLoading: false,
isConfigOpen: false,
@@ -179,6 +200,13 @@ export const useConfigStore = create<ConfigStore>()(
[key]: value,
},
})),
updateWebdavConfig: (key, value) =>
set((state) => ({
webdav: {
...state.webdav,
[key]: value,
},
})),
loadPublicSettings: async () => {
if (get().isPublicSettingsLoading) return;
set({ isPublicSettingsLoading: true });
@@ -195,12 +223,15 @@ export const useConfigStore = create<ConfigStore>()(
}),
{
name: CONFIG_STORE_KEY,
partialize: (state) => ({ config: state.config }),
partialize: (state) => ({ config: state.config, webdav: state.webdav }),
merge: (persisted, current) => {
const persistedConfig = ((persisted as Partial<ConfigStore>).config || {}) as Partial<AiConfig>;
const persistedState = (persisted || {}) as Partial<ConfigStore>;
const persistedConfig = (persistedState.config || {}) as Partial<AiConfig>;
const persistedWebdav = (persistedState.webdav || {}) as Partial<WebdavSyncConfig>;
const config = { ...defaultConfig, ...persistedConfig };
return {
...current,
webdav: { ...defaultWebdavSyncConfig, ...persistedWebdav },
config: {
...config,
channelMode: config.channelMode || "remote",