diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c196dbaa5..627f0143a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -81,6 +81,7 @@ func main() { handler.RegisterAdminAnalyticsRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) + handler.RegisterLibTVRoutes(api, svc) // 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。 handler.RegisterChannelModelRoutes(api, svc) handler.RegisterLogicalModelRoutes(api, svc) diff --git a/backend/internal/handler/libtv.go b/backend/internal/handler/libtv.go new file mode 100644 index 000000000..734aaa76f --- /dev/null +++ b/backend/internal/handler/libtv.go @@ -0,0 +1,86 @@ +package handler + +import ( + "net/http" + "strings" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterLibTVRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/settings/libtv", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminLibTVSetting(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.PATCH("/admin/settings/libtv", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req service.LibTVSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + setting, err := svc.UpdateLibTVSetting(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.POST("/admin/settings/libtv/test", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req service.LibTVImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.TestLibTV(user, strings.TrimSpace(req.UUID)); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) + r.POST("/canvas-projects/:id/import/libtv", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if user == nil { + failService(c, service.Unauthorized("请先登录")) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req service.LibTVImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.ImportLibTV(user.ID, c.Param("id"), strings.TrimSpace(req.UUID)) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 1b19fc963..59e96192b 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -12,7 +12,7 @@ type Resource struct { Provider string `json:"provider" gorm:"size:24"` Endpoint string `json:"endpoint"` Bucket string `json:"bucket" gorm:"size:160"` - // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的存储与密钥;同一存储位置的 CDN 域名可跟随当前配置。 + // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的存储与密钥;只有同一存储位置才可复用当前 CDN。 StorageSettingID string `json:"-" gorm:"index;size:36"` ObjectKey string `json:"objectKey" gorm:"index"` PublicURL string `json:"publicUrl"` diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index de1697ba0..b2d8356ef 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -781,6 +781,12 @@ func (r *Repository) LatestUserOSSSetting(userID string) (*model.UserOSSSetting, return &setting, nil } +func (r *Repository) UserOSSSettingsForUser(userID string) ([]model.UserOSSSetting, error) { + var settings []model.UserOSSSetting + err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").Find(&settings).Error + return settings, err +} + func (r *Repository) UserOSSSettingForUser(userID string, id string) (*model.UserOSSSetting, error) { var setting model.UserOSSSetting if err := r.db.First(&setting, "id = ? AND user_id = ?", id, userID).Error; err != nil { diff --git a/backend/internal/service/libtv.go b/backend/internal/service/libtv.go new file mode 100644 index 000000000..6fffc5d0e --- /dev/null +++ b/backend/internal/service/libtv.go @@ -0,0 +1,358 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +const ( + libTVSettingKey = "libtv" + libTVDetailURL = "https://api.liblib.tv/api/canvas/project/detail" + libTVMaxResponseBytes = 8 << 20 +) + +type LibTVSettingRequest struct { + Enabled bool `json:"enabled"` + Token string `json:"token"` + ClearToken bool `json:"clearToken"` +} + +type PublicLibTVSetting struct { + Enabled bool `json:"enabled"` + HasToken bool `json:"hasToken"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +type libTVSettingValue struct { + Enabled bool `json:"enabled"` + Token string `json:"token"` +} + +type LibTVImportRequest struct { + UUID string `json:"uuid"` +} + +type LibTVImportResult struct { + BatchID string `json:"batchId"` + BatchCreatedAt time.Time `json:"batchCreatedAt"` + ProjectUUID string `json:"projectUuid"` + ProjectName string `json:"projectName"` + Nodes []LibTVCanvasNode `json:"nodes"` + Connections []LibTVCanvasConnection `json:"connections"` + ImportedNodeCount int `json:"importedNodeCount"` + ImportedConnectionCount int `json:"importedConnectionCount"` + SkippedNodes []LibTVImportIssue `json:"skippedNodes"` + SkippedConnections []LibTVImportIssue `json:"skippedConnections"` + Warnings []LibTVImportWarning `json:"warnings"` + MultiResultNodeCount int `json:"multiResultNodeCount"` + StaleNodeCount int `json:"staleNodeCount"` + ReusedFailedNodeCount int `json:"reusedFailedNodeCount"` + PlaceholderNodeCount int `json:"placeholderNodeCount"` + ConvertedSpecialCount int `json:"convertedSpecialCount"` +} + +type LibTVCanvasNode struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Content string `json:"content"` + Prompt string `json:"prompt,omitempty"` + Model string `json:"model,omitempty"` + NaturalWidth int `json:"naturalWidth,omitempty"` + NaturalHeight int `json:"naturalHeight,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + MimeType string `json:"mimeType,omitempty"` + Status string `json:"status,omitempty"` + ErrorDetails string `json:"errorDetails,omitempty"` + Metadata LibTVImportMetadata `json:"metadata"` +} + +type LibTVImportMetadata struct { + Provider string `json:"provider"` + ProjectUUID string `json:"projectUuid"` + NodeKey string `json:"nodeKey"` + BatchID string `json:"batchId"` + SourceType string `json:"sourceType,omitempty"` + StyleAssetUUID string `json:"styleAssetUuid,omitempty"` + StyleVersionUUID string `json:"styleVersionUuid,omitempty"` + StyleName string `json:"styleName,omitempty"` +} + +type LibTVCanvasConnection struct { + ID string `json:"id"` + FromNodeID string `json:"fromNodeId"` + ToNodeID string `json:"toNodeId"` +} + +type LibTVImportIssue struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Reason string `json:"reason"` +} + +type LibTVImportWarning struct { + ID string `json:"id,omitempty"` + Message string `json:"message"` +} + +type libTVEnvelope struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` + Msg string `json:"msg"` +} + +type libTVDetail struct { + ProjectMeta struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Effective struct { + CanRead bool `json:"canRead"` + CanCopy bool `json:"canCopy"` + } `json:"effective"` + } `json:"projectMeta"` + NodeList []libTVRawNode `json:"nodeList"` + ConnectionList []libTVRawConnection `json:"connectionList"` +} + +type libTVRawNode struct { + NodeKey string `json:"nodeKey"` + Name string `json:"name"` + Data string `json:"data"` + Position struct { + X string `json:"positionX"` + Y string `json:"positionY"` + } `json:"position"` + Measured struct { + Width string `json:"width"` + Height string `json:"height"` + } `json:"measured"` + TaskInfo struct { + Status int `json:"status"` + FailedReason string `json:"failedReason"` + } `json:"taskInfo"` + IsStale bool `json:"isStale"` +} + +type libTVRawConnection struct { + ConnectionID string `json:"connectionId"` + Source string `json:"source"` + Target string `json:"target"` +} + +type libTVNodeData struct { + Type string `json:"type"` + URL []string `json:"url"` + CoverURL string `json:"coverUrl"` + StyleAssetUUID string `json:"styleAssetUuid"` + StyleVersionUUID string `json:"styleVersionUuid"` + StyleName string `json:"styleName"` + Params map[string]any `json:"params"` + TaskInfo struct { + Status int `json:"status"` + FailedReason string `json:"failedReason"` + } `json:"taskInfo"` + IsStale bool `json:"isStale"` + ResourceMeta struct { + Items []struct { + Width int `json:"width"` + Height int `json:"height"` + DurationSec float64 `json:"durationSec"` + } `json:"items"` + } `json:"_resourceMeta"` +} + +func (s *Service) AdminLibTVSetting(actor *model.User) (*PublicLibTVSetting, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + setting, value, err := s.readLibTVSetting() + if err != nil { + return nil, err + } + return publicLibTVSetting(setting, value), nil +} + +func (s *Service) UpdateLibTVSetting(actor *model.User, req LibTVSettingRequest) (*PublicLibTVSetting, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + _, current, err := s.readLibTVSetting() + if err != nil { + return nil, err + } + token := strings.TrimSpace(req.Token) + if req.ClearToken { + token = "" + } else if token == "" { + token = current.Token + } + if req.Enabled && token == "" { + return nil, BadAuthRequest("启用 LibTV 前请先配置 Token") + } + protected, err := s.encryptSettingSecret(token) + if err != nil { + return nil, err + } + valueJSON, err := json.Marshal(libTVSettingValue{Enabled: req.Enabled, Token: protected}) + if err != nil { + return nil, err + } + setting := &model.SystemSetting{Key: libTVSettingKey, ValueJSON: string(valueJSON), UpdatedBy: actor.ID} + if existing, lookupErr := s.repo.SystemSetting(libTVSettingKey); lookupErr == nil { + setting.CreatedAt = existing.CreatedAt + } else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) { + return nil, lookupErr + } + if err := s.repo.SaveSystemSetting(setting); err != nil { + return nil, err + } + return publicLibTVSetting(setting, libTVSettingValue{Enabled: req.Enabled, Token: token}), nil +} + +func (s *Service) TestLibTV(actor *model.User, projectUUID string) error { + if actor == nil { + return Unauthorized("请先登录") + } + if err := s.RequireAdmin(actor); err != nil { + return err + } + _, value, err := s.readLibTVSetting() + if err != nil { + return err + } + if value.Token == "" { + return BadAuthRequest("尚未配置 LibTV Token") + } + _, err = s.fetchLibTVDetail(strings.TrimSpace(projectUUID), value.Token) + return err +} + +func (s *Service) ImportLibTV(userID, canvasProjectID, projectUUID string) (*LibTVImportResult, error) { + userID = strings.TrimSpace(userID) + canvasProjectID = strings.TrimSpace(canvasProjectID) + if userID == "" || canvasProjectID == "" { + return nil, Unauthorized("请先打开已同步的影策画布") + } + if _, err := s.repo.CanvasProjectForUser(userID, canvasProjectID); err != nil { + return nil, err + } + _, value, err := s.readLibTVSetting() + if err != nil { + return nil, err + } + if !value.Enabled || value.Token == "" { + return nil, BadAuthRequest("LibTV 尚未启用或未配置 Token") + } + detail, err := s.fetchLibTVDetail(strings.TrimSpace(projectUUID), value.Token) + if err != nil { + return nil, err + } + return adaptLibTVDetail(detail) +} + +func (s *Service) readLibTVSetting() (*model.SystemSetting, libTVSettingValue, error) { + setting, err := s.repo.SystemSetting(libTVSettingKey) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, libTVSettingValue{}, nil + } + if err != nil { + return nil, libTVSettingValue{}, err + } + value := libTVSettingValue{} + if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil { + return nil, value, errors.New("LibTV 配置格式无效") + } + if value.Token != "" { + plain, err := s.decryptSettingSecret(value.Token) + if err != nil { + return nil, value, err + } + value.Token = plain + } + return setting, value, nil +} + +func publicLibTVSetting(setting *model.SystemSetting, value libTVSettingValue) *PublicLibTVSetting { + result := &PublicLibTVSetting{Enabled: value.Enabled, HasToken: strings.TrimSpace(value.Token) != ""} + if setting != nil { + result.UpdatedAt = setting.UpdatedAt + } + return result +} + +func (s *Service) fetchLibTVDetail(projectUUID, token string) (*libTVDetail, error) { + projectUUID = strings.TrimSpace(projectUUID) + if !isLibTVProjectUUID(projectUUID) { + return nil, BadAuthRequest("LibTV 画布 UUID 格式无效") + } + u, err := url.Parse(libTVDetailURL) + if err != nil { + return nil, err + } + query := u.Query() + query.Set("uuid", projectUUID) + u.RawQuery = query.Encode() + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("token", token) + ApplyDefaultOutboundHeaders(req) + client := OutboundHTTPClient(20 * time.Second) + // Token 是 LibTV 专用凭证,禁止重定向以避免自定义请求头被带到其他主机。 + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return errors.New("LibTV API 不允许重定向") + } + resp, err := client.Do(req) + if err != nil { + return nil, errors.New("LibTV 请求失败,请检查网络或 Token") + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, libTVMaxResponseBytes+1)) + if err != nil { + return nil, errors.New("读取 LibTV 响应失败") + } + if len(body) > libTVMaxResponseBytes { + return nil, errors.New("LibTV 响应过大") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("LibTV 请求失败(HTTP %d)", resp.StatusCode) + } + var envelope libTVEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, errors.New("LibTV 响应格式无效") + } + if envelope.Code != 0 { + msg := strings.TrimSpace(envelope.Msg) + if msg == "" { + msg = "LibTV 返回业务错误" + } + return nil, errors.New(msg) + } + var detail libTVDetail + if err := json.Unmarshal(envelope.Data, &detail); err != nil { + return nil, errors.New("LibTV 画布数据格式无效") + } + if strings.TrimSpace(detail.ProjectMeta.UUID) == "" { + detail.ProjectMeta.UUID = projectUUID + } + if !detail.ProjectMeta.Effective.CanRead || !detail.ProjectMeta.Effective.CanCopy { + return nil, errors.New("当前 LibTV 画布不允许复制") + } + return &detail, nil +} diff --git a/backend/internal/service/libtv_adapter.go b/backend/internal/service/libtv_adapter.go new file mode 100644 index 000000000..16d985061 --- /dev/null +++ b/backend/internal/service/libtv_adapter.go @@ -0,0 +1,236 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "net/url" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +func adaptLibTVDetail(detail *libTVDetail) (*LibTVImportResult, error) { + if detail == nil { + return nil, errors.New("LibTV 画布数据为空") + } + now := time.Now() + batchID := strconv.FormatInt(now.UnixMilli(), 36) + "-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8] + result := &LibTVImportResult{ + BatchID: batchID, + BatchCreatedAt: now, + ProjectUUID: detail.ProjectMeta.UUID, + ProjectName: detail.ProjectMeta.Name, + Nodes: make([]LibTVCanvasNode, 0), + Connections: make([]LibTVCanvasConnection, 0), + SkippedNodes: make([]LibTVImportIssue, 0), + SkippedConnections: make([]LibTVImportIssue, 0), + Warnings: make([]LibTVImportWarning, 0), + } + mapping := make(map[string]string, len(detail.NodeList)) + seenNodeKeys := make(map[string]struct{}, len(detail.NodeList)) + for _, raw := range detail.NodeList { + nodeKey := strings.TrimSpace(raw.NodeKey) + if nodeKey == "" { + result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{Name: raw.Name, Reason: "节点缺少 nodeKey"}) + continue + } + if _, exists := seenNodeKeys[nodeKey]; exists { + result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "节点 nodeKey 重复"}) + continue + } + seenNodeKeys[nodeKey] = struct{}{} + + var data libTVNodeData + if err := json.Unmarshal([]byte(raw.Data), &data); err != nil { + result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "节点数据格式无效"}) + continue + } + taskStatus := data.TaskInfo.Status + if taskStatus == 0 && raw.TaskInfo.Status != 0 { + taskStatus = raw.TaskInfo.Status + } + sourceType := strings.ToLower(strings.TrimSpace(data.Type)) + kind := sourceType + if sourceType == "material-style" { + // 风格素材没有普通生成 URL,但有可复用封面时可以降级为影策图片参考节点。 + kind = "image" + } else if kind != "image" && kind != "video" { + result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "暂不支持的节点类型"}) + continue + } + mediaURL, mediaIndex := firstLibTVMediaURL(data.URL) + if mediaURL == "" && sourceType == "material-style" { + mediaURL, mediaIndex = firstLibTVMediaURL([]string{data.CoverURL}) + } + + x, okX := finiteFloat(raw.Position.X) + y, okY := finiteFloat(raw.Position.Y) + if !okX || !okY { + result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "节点坐标无效"}) + continue + } + width, okWidth := positiveFiniteFloat(raw.Measured.Width) + height, okHeight := positiveFiniteFloat(raw.Measured.Height) + if !okWidth { + width = 480 + } + if !okHeight { + height = 300 + } + item := LibTVCanvasNode{ + ID: "libtv-" + batchID + "-" + nodeKey, + Type: kind, + Title: strings.TrimSpace(raw.Name), + X: x, + Y: y, + Width: width, + Height: height, + Content: mediaURL, + Prompt: mapString(data.Params, "prompt"), + Model: mapString(data.Params, "model"), + Metadata: LibTVImportMetadata{ + Provider: "libtv", + ProjectUUID: result.ProjectUUID, + NodeKey: nodeKey, + BatchID: batchID, + SourceType: sourceType, + StyleAssetUUID: strings.TrimSpace(data.StyleAssetUUID), + StyleVersionUUID: strings.TrimSpace(data.StyleVersionUUID), + StyleName: strings.TrimSpace(data.StyleName), + }, + } + if item.Title == "" { + item.Title = kind + " 节点" + } + if mediaIndex >= 0 && mediaIndex < len(data.ResourceMeta.Items) { + resource := data.ResourceMeta.Items[mediaIndex] + item.NaturalWidth = resource.Width + item.NaturalHeight = resource.Height + if resource.DurationSec > 0 && !math.IsNaN(resource.DurationSec) && !math.IsInf(resource.DurationSec, 0) { + item.DurationMs = int64(math.Round(resource.DurationSec * 1000)) + } + } + item.MimeType = inferMediaMime(kind, mediaURL) + if mediaURL == "" { + result.PlaceholderNodeCount++ + if taskStatus == 3 { + // 没有历史结果的失败节点仍保留拓扑,并以失败占位状态呈现。 + item.Status = "error" + item.ErrorDetails = firstNonEmptyString(data.TaskInfo.FailedReason, raw.TaskInfo.FailedReason, "LibTV 生成任务失败") + } else { + item.Status = "idle" + } + } else { + item.Status = "success" + if taskStatus == 3 { + // 最近一次任务失败不应覆盖节点中仍可用的历史资源。 + result.ReusedFailedNodeCount++ + } + } + mapping[nodeKey] = item.ID + result.Nodes = append(result.Nodes, item) + if sourceType == "material-style" { + result.ConvertedSpecialCount++ + } + if !okWidth || !okHeight { + result.Warnings = append(result.Warnings, LibTVImportWarning{ID: nodeKey, Message: fmt.Sprintf("节点“%s”尺寸无效,已使用默认尺寸。", item.Title)}) + } + if len(data.URL) > 1 && mediaURL != "" { + result.MultiResultNodeCount++ + result.Warnings = append(result.Warnings, LibTVImportWarning{ID: nodeKey, Message: fmt.Sprintf("节点“%s”包含 %d 个结果,已导入第 %d 个。", item.Title, len(data.URL), mediaIndex+1)}) + } + if data.IsStale || raw.IsStale { + result.StaleNodeCount++ + result.Warnings = append(result.Warnings, LibTVImportWarning{ID: nodeKey, Message: fmt.Sprintf("节点“%s”已标记为过期,但现有资源仍已导入。", item.Title)}) + } + } + + seenConnectionIDs := make(map[string]struct{}, len(detail.ConnectionList)) + for _, raw := range detail.ConnectionList { + connectionID := strings.TrimSpace(raw.ConnectionID) + if connectionID == "" { + result.SkippedConnections = append(result.SkippedConnections, LibTVImportIssue{Reason: "连接缺少 connectionId"}) + continue + } + if _, exists := seenConnectionIDs[connectionID]; exists { + result.SkippedConnections = append(result.SkippedConnections, LibTVImportIssue{ID: connectionID, Reason: "连接 connectionId 重复"}) + continue + } + seenConnectionIDs[connectionID] = struct{}{} + from, okFrom := mapping[strings.TrimSpace(raw.Source)] + to, okTo := mapping[strings.TrimSpace(raw.Target)] + if !okFrom || !okTo { + result.SkippedConnections = append(result.SkippedConnections, LibTVImportIssue{ID: connectionID, Reason: "连接端点节点未导入"}) + continue + } + result.Connections = append(result.Connections, LibTVCanvasConnection{ID: "libtv-" + batchID + "-" + connectionID, FromNodeID: from, ToNodeID: to}) + } + result.ImportedNodeCount = len(result.Nodes) + result.ImportedConnectionCount = len(result.Connections) + if result.ImportedNodeCount == 0 { + return nil, errors.New("LibTV 画布没有可导入的有效节点") + } + return result, nil +} + +func mapString(values map[string]any, key string) string { + value, _ := values[key].(string) + return strings.TrimSpace(value) +} + +func isLibTVProjectUUID(value string) bool { + if len(value) != 32 { + return false + } + for _, char := range value { + if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) { + return false + } + } + return true +} + +func firstLibTVMediaURL(values []string) (string, int) { + for index, value := range values { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { + continue + } + return parsed.String(), index + } + return "", -1 +} + +func finiteFloat(value string) (float64, bool) { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return 0, false + } + return parsed, true +} + +func positiveFiniteFloat(value string) (float64, bool) { + parsed, ok := finiteFloat(value) + return parsed, ok && parsed > 0 +} + +func inferMediaMime(kind, rawURL string) string { + lower := strings.ToLower(rawURL) + if strings.Contains(lower, ".png") { + return "image/png" + } + if strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg") { + return "image/jpeg" + } + if strings.Contains(lower, ".webp") { + return "image/webp" + } + if kind == "video" { + return "video/mp4" + } + return "image/*" +} diff --git a/backend/internal/service/libtv_test.go b/backend/internal/service/libtv_test.go new file mode 100644 index 000000000..b30979fab --- /dev/null +++ b/backend/internal/service/libtv_test.go @@ -0,0 +1,201 @@ +package service + +import ( + "strings" + "testing" +) + +func TestAdaptLibTVDetailSelectsFirstMediaAndReportsPartialImport(t *testing.T) { + detail := &libTVDetail{} + detail.ProjectMeta.UUID = "project-uuid" + detail.ProjectMeta.Name = "测试画布" + detail.NodeList = []libTVRawNode{ + { + NodeKey: "image-1", + Name: "多图节点", + Data: `{"type":"image","url":["https://example.com/first.png","https://example.com/second.png"],"params":{"prompt":"prompt","model":"model"},"_resourceMeta":{"items":[{"width":1024,"height":768}]}}`, + Position: struct { + X string `json:"positionX"` + Y string `json:"positionY"` + }{X: "12", Y: "34"}, + Measured: struct { + Width string `json:"width"` + Height string `json:"height"` + }{Width: "640", Height: "360"}, + }, + { + NodeKey: "failed-video", + Name: "失败视频", + Data: `{"type":"video","url":[],"taskInfo":{"status":3,"failedReason":"生成失败"}}`, + Position: struct { + X string `json:"positionX"` + Y string `json:"positionY"` + }{X: "500", Y: "600"}, + }, + { + NodeKey: "stale-video", + Name: "过期视频", + Data: `{"type":"video","url":["https://example.com/video.mp4"],"taskInfo":{"status":2},"isStale":true,"_resourceMeta":{"items":[{"width":1920,"height":1080,"durationSec":5.2506}]}}`, + Position: struct { + X string `json:"positionX"` + Y string `json:"positionY"` + }{X: "900", Y: "1200"}, + Measured: struct { + Width string `json:"width"` + Height string `json:"height"` + }{Width: "640", Height: "360"}, + }, + } + detail.ConnectionList = []libTVRawConnection{ + {ConnectionID: "valid", Source: "image-1", Target: "stale-video"}, + {ConnectionID: "dangling", Source: "image-1", Target: "failed-video"}, + } + + result, err := adaptLibTVDetail(detail) + if err != nil { + t.Fatalf("adaptLibTVDetail() error = %v", err) + } + if result.ImportedNodeCount != 3 || result.ImportedConnectionCount != 2 { + t.Fatalf("counts = (%d, %d), want (3, 2)", result.ImportedNodeCount, result.ImportedConnectionCount) + } + if len(result.SkippedNodes) != 0 || len(result.SkippedConnections) != 0 { + t.Fatalf("unexpected skipped items: nodes=%#v connections=%#v", result.SkippedNodes, result.SkippedConnections) + } + if result.PlaceholderNodeCount != 1 || result.ReusedFailedNodeCount != 0 { + t.Fatalf("failure handling counts = (placeholder=%d, reused=%d), want (1, 0)", result.PlaceholderNodeCount, result.ReusedFailedNodeCount) + } + if result.BatchCreatedAt.IsZero() { + t.Fatal("batch created time must not be zero") + } + if got := result.Nodes[0]; got.Content != "https://example.com/first.png" || got.X != 12 || got.Y != 34 || got.Width != 640 || got.Height != 360 { + t.Fatalf("mapped image = %#v", got) + } + if got := result.Nodes[0]; got.Prompt != "prompt" || got.Model != "model" || got.NaturalWidth != 1024 || got.NaturalHeight != 768 { + t.Fatalf("mapped image metadata = %#v", got) + } + if got := result.Nodes[1]; got.DurationMs != 0 || got.MimeType != "video/mp4" || got.Status != "error" || got.ErrorDetails != "生成失败" { + t.Fatalf("mapped video metadata = %#v", got) + } + if got := result.Nodes[0].Metadata.Provider; got != "libtv" { + t.Fatalf("metadata provider = %q, want libtv", got) + } + if len(result.Warnings) != 3 { + t.Fatalf("warnings = %#v", result.Warnings) + } +} + +func TestAdaptLibTVDetailKeepsFailedNodeWithHistoricalMedia(t *testing.T) { + detail := &libTVDetail{NodeList: []libTVRawNode{{ + NodeKey: "failed-image", + Data: `{"type":"image","url":["https://example.com/history.png"],"taskInfo":{"status":3,"failedReason":"本次生成失败"}}`, + Position: struct { + X string `json:"positionX"` + Y string `json:"positionY"` + }{X: "10", Y: "20"}, + }}} + + result, err := adaptLibTVDetail(detail) + if err != nil { + t.Fatalf("adaptLibTVDetail() error = %v", err) + } + if len(result.Nodes) != 1 || result.Nodes[0].Content != "https://example.com/history.png" { + t.Fatalf("historical media node = %#v", result.Nodes) + } + if result.Nodes[0].Status != "success" || result.ReusedFailedNodeCount != 1 { + t.Fatalf("failure reuse = node=%#v count=%d", result.Nodes[0], result.ReusedFailedNodeCount) + } +} + +func TestAdaptLibTVDetailConvertsMaterialStyleToImage(t *testing.T) { + detail := &libTVDetail{NodeList: []libTVRawNode{{ + NodeKey: "style-1", + Name: "风格素材", + Data: `{"type":"material-style","coverUrl":"https://example.com/style.png","styleAssetUuid":"asset-1","styleVersionUuid":"version-1","styleName":"电影感"}`, + Position: struct { + X string `json:"positionX"` + Y string `json:"positionY"` + }{X: "1", Y: "2"}, + }}} + + result, err := adaptLibTVDetail(detail) + if err != nil { + t.Fatalf("adaptLibTVDetail() error = %v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("nodes = %#v", result.Nodes) + } + node := result.Nodes[0] + if node.Type != "image" || node.Content != "https://example.com/style.png" || result.ConvertedSpecialCount != 1 { + t.Fatalf("converted style node = %#v count=%d", node, result.ConvertedSpecialCount) + } + if node.Metadata.StyleAssetUUID != "asset-1" || node.Metadata.StyleVersionUUID != "version-1" || node.Metadata.StyleName != "电影感" { + t.Fatalf("style metadata = %#v", node.Metadata) + } +} + +func TestAdaptLibTVDetailGeneratesDistinctBatchIDs(t *testing.T) { + detail := &libTVDetail{} + detail.ProjectMeta.UUID = "project-uuid" + detail.NodeList = []libTVRawNode{{ + NodeKey: "image-1", + Data: `{"type":"image","url":["https://example.com/image.png"]}`, + Position: struct { + X string `json:"positionX"` + Y string `json:"positionY"` + }{X: "0", Y: "0"}, + }} + + first, err := adaptLibTVDetail(detail) + if err != nil { + t.Fatalf("first adaptLibTVDetail() error = %v", err) + } + second, err := adaptLibTVDetail(detail) + if err != nil { + t.Fatalf("second adaptLibTVDetail() error = %v", err) + } + if first.BatchID == second.BatchID { + t.Fatalf("batch IDs are not unique: %q", first.BatchID) + } + if !strings.HasPrefix(first.Nodes[0].ID, "libtv-"+first.BatchID+"-") { + t.Fatalf("node ID %q does not contain batch ID %q", first.Nodes[0].ID, first.BatchID) + } +} + +func TestAdaptLibTVDetailRejectsCanvasWithoutImportableNodes(t *testing.T) { + detail := &libTVDetail{NodeList: []libTVRawNode{{ + NodeKey: "unsupported", + Data: `{"type":"text","url":[]}`, + }}} + + if _, err := adaptLibTVDetail(detail); err == nil { + t.Fatal("adaptLibTVDetail() error = nil, want an error") + } +} + +func TestAdaptLibTVDetailKeepsReportListsNonNilAndResourceIndex(t *testing.T) { + detail := &libTVDetail{} + detail.ProjectMeta.UUID = "project-uuid" + detail.NodeList = []libTVRawNode{{ + NodeKey: "image-1", + Data: `{"type":"image","url":["http://example.com/rejected.png","https://example.com/selected.png"],"_resourceMeta":{"items":[{"width":100,"height":100},{"width":200,"height":300}]}}`, + Position: struct { + X string `json:"positionX"` + Y string `json:"positionY"` + }{X: "10", Y: "20"}, + Measured: struct { + Width string `json:"width"` + Height string `json:"height"` + }{Width: "200", Height: "300"}, + }} + + result, err := adaptLibTVDetail(detail) + if err != nil { + t.Fatalf("adaptLibTVDetail() error = %v", err) + } + if result.SkippedNodes == nil || result.SkippedConnections == nil || result.Warnings == nil { + t.Fatalf("report slices must not be nil: %#v", result) + } + if got := result.Nodes[0]; got.Content != "https://example.com/selected.png" || got.NaturalWidth != 200 || got.NaturalHeight != 300 { + t.Fatalf("selected media metadata = %#v", got) + } +} diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index abb85fee8..cf89b9502 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -695,29 +695,58 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource) if currentErr != nil { return ossSettingValue{}, currentErr } - // 密钥继续固定在历史版本;同一存储位置的 CDN 域名跟随当前配置,使已有资源也立即切换。 + // 密钥固定在资源绑定的历史版本;只有存储位置完全一致时,才允许沿用当前 CDN。 if resourceStorageMatches(current, resource) { setting.CDNBaseURL = current.CDNBaseURL } } } else { - _, setting, err = s.readOSSSetting() + // 早期资源没有 StorageSettingID,但资源本身仍记录了 provider/endpoint/bucket。 + // 先从用户 OSS 历史版本中按存储位置反查,不能把当前七牛配置猜给历史阿里云对象。 + setting, err = s.userOSSSettingForResource(userID, resource) + if errors.Is(err, gorm.ErrRecordNotFound) { + _, setting, err = s.readOSSSetting() + } } if err != nil { return ossSettingValue{}, err } + resourceProvider := strings.ToLower(strings.TrimSpace(resource.Provider)) + resourceMatchesSetting := resourceStorageMatches(setting, resource) setting, err = ossSettingForProvider(setting, firstNonEmpty(resource.Provider, setting.Provider)) if err != nil { return ossSettingValue{}, err } setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint) setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket) + // CDN 是具体存储位置的出口,不得在 provider/endpoint/bucket 不匹配时继续沿用, + // 否则切换到七牛后会把历史阿里云 objectKey 拼成七牛域名。 + if !resourceMatchesSetting || (resourceProvider != "" && resourceProvider != setting.Provider) { + setting.CDNBaseURL = "" + } if setting.AccessKeyID == "" || setting.AccessKeySecret == "" { return ossSettingValue{}, errors.New("对象存储访问密钥不可用") } return setting, nil } +func (s *Service) userOSSSettingForResource(userID string, resource *model.Resource) (ossSettingValue, error) { + settings, err := s.repo.UserOSSSettingsForUser(userID) + if err != nil { + return ossSettingValue{}, err + } + for index := range settings { + value, valueErr := s.userOSSSettingValue(&settings[index]) + if valueErr != nil { + return ossSettingValue{}, valueErr + } + if resourceStorageMatches(value, resource) { + return value, nil + } + } + return ossSettingValue{}, gorm.ErrRecordNotFound +} + func resourceStorageMatches(setting ossSettingValue, resource *model.Resource) bool { if resource == nil { return false diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index 8532bcc25..1786d832a 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -261,7 +261,7 @@ func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing. _, err := ossSettingFromRequest(OSSSettingRequest{ Enabled: true, Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", }, ossSettingValue{Provider: aliyunOSSProvider, AccessKeySecret: "aliyun-secret"}) - if err == nil || !strings.Contains(err.Error(), "AccessKey Secret") { + if err == nil || !strings.Contains(err.Error(), "访问密钥 SecretKey") { t.Fatalf("ossSettingFromRequest() error = %v", err) } next, err := ossSettingFromRequest(OSSSettingRequest{ @@ -449,6 +449,101 @@ func TestCurrentUserCDNSettingAppliesToHistoricalResourcesInSameStorage(t *testi } } +func TestBoundHistoricalUserResourceDoesNotFollowCurrentProviderCDN(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + newTestServer := func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + } + aliyunEndpoint := newTestServer() + aliyunCDN := newTestServer() + qiniuEndpoint := newTestServer() + qiniuCDN := newTestServer() + defer aliyunEndpoint.Close() + defer aliyunCDN.Close() + defer qiniuEndpoint.Close() + defer qiniuCDN.Close() + + svc := newResourceTestService(t) + actor := &model.User{ID: "user-1"} + if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{ + Enabled: true, Provider: aliyunOSSProvider, Endpoint: aliyunEndpoint.URL, CDNBaseURL: aliyunCDN.URL, Bucket: "aliyun-bucket", + AccessKeyID: "aliyun-access", AccessKeySecret: "aliyun-secret", + }); err != nil { + t.Fatal(err) + } + historical, _, err := svc.readUserOSSSetting(actor.ID) + if err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{ + Enabled: true, Provider: qiniuKodoProvider, Endpoint: qiniuEndpoint.URL, CDNBaseURL: qiniuCDN.URL, Bucket: "qiniu-bucket", + AccessKeyID: "qiniu-access", AccessKeySecret: "qiniu-secret", + }); err != nil { + t.Fatal(err) + } + resource := model.Resource{ + ID: "resource-bound-historical-storage", UserID: actor.ID, Kind: "image", Status: model.ResourceStatusReady, + Provider: aliyunOSSProvider, Endpoint: aliyunEndpoint.URL, Bucket: "aliyun-bucket", StorageSettingID: historical.ID, + ObjectKey: "ai/users/user-1/image/bound.png", MimeType: "image/png", + } + if err := svc.repo.CreateResource(&resource); err != nil { + t.Fatal(err) + } + delivery, err := svc.PrepareResourceDelivery(actor.ID, resource.ID, ResourceDeliveryOptions{}) + if err != nil { + t.Fatal(err) + } + want := aliyunCDN.URL + "/ai/users/user-1/image/bound.png" + if delivery.RedirectURL != want || strings.Contains(delivery.RedirectURL, qiniuCDN.URL) { + t.Fatalf("PrepareResourceDelivery(bound historical resource) = %q, want %q", delivery.RedirectURL, want) + } +} + +func TestHistoricalUserResourceWithoutStorageSettingIDKeepsItsProviderCDN(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + newTestServer := func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + } + aliyunEndpoint := newTestServer() + aliyunCDN := newTestServer() + qiniuEndpoint := newTestServer() + qiniuCDN := newTestServer() + defer aliyunEndpoint.Close() + defer aliyunCDN.Close() + defer qiniuEndpoint.Close() + defer qiniuCDN.Close() + svc := newResourceTestService(t) + actor := &model.User{ID: "user-1"} + if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{ + Enabled: true, Provider: aliyunOSSProvider, Endpoint: aliyunEndpoint.URL, CDNBaseURL: aliyunCDN.URL, Bucket: "aliyun-bucket", + AccessKeyID: "aliyun-access", AccessKeySecret: "aliyun-secret", + }); err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{ + Enabled: true, Provider: qiniuKodoProvider, Endpoint: qiniuEndpoint.URL, CDNBaseURL: qiniuCDN.URL, Bucket: "qiniu-bucket", + AccessKeyID: "qiniu-access", AccessKeySecret: "qiniu-secret", + }); err != nil { + t.Fatal(err) + } + resource := model.Resource{ + ID: "resource-legacy-user-storage", UserID: actor.ID, Kind: "image", Status: model.ResourceStatusReady, + Provider: aliyunOSSProvider, Endpoint: aliyunEndpoint.URL, Bucket: "aliyun-bucket", + ObjectKey: "ai/users/user-1/image/legacy.png", MimeType: "image/png", + } + if err := svc.repo.CreateResource(&resource); err != nil { + t.Fatal(err) + } + delivery, err := svc.PrepareResourceDelivery(actor.ID, resource.ID, ResourceDeliveryOptions{}) + if err != nil { + t.Fatal(err) + } + want := aliyunCDN.URL + "/ai/users/user-1/image/legacy.png" + if delivery.RedirectURL != want || strings.Contains(delivery.RedirectURL, qiniuCDN.URL) { + t.Fatalf("PrepareResourceDelivery(legacy user resource) = %q, want %q", delivery.RedirectURL, want) + } +} + func TestPrepareResourceDeliveryKeepsForcedOriginDirectWithoutCDN(t *testing.T) { svc := newResourceTestService(t) settingJSON, _ := json.Marshal(ossSettingValue{ diff --git a/web/package.json b/web/package.json index 4f938780b..bcaef5faf 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/asset-media-preview.tsx b/web/src/components/asset-media-preview.tsx index de1dbbee5..da5961b6b 100644 --- a/web/src/components/asset-media-preview.tsx +++ b/web/src/components/asset-media-preview.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; +import { CachedResourceImage } from "@/components/cached-resource-image"; import type { Asset } from "@/stores/use-asset-store"; type AssetMediaPreviewProps = { @@ -32,7 +33,8 @@ export function AssetMediaPreview({ asset, alt, className = "", fallback = null ); } + const storageKey = asset.kind === "image" ? asset.data.storageKey : undefined; const imageUrl = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : ""); - if (!imageUrl) return fallback; - return {alt}; + if (!imageUrl && !storageKey) return fallback; + return ; } diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 31f89ff25..aa76e4737 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -3,6 +3,7 @@ import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AssetMediaPreview } from "@/components/asset-media-preview"; +import { CachedResourceImage } from "@/components/cached-resource-image"; import { cn } from "@/lib/utils"; import type { Asset } from "@/stores/use-asset-store"; @@ -13,6 +14,7 @@ export type AssetLibraryPickerItem = { kindLabel: string; asset?: Asset; imageUrl?: string; + imageStorageKey?: string; imageFit?: "cover" | "contain"; description?: string; searchText?: string; @@ -210,7 +212,7 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem return ( + + ) : null} + + + + } + > +
+
+
+ + setToken(event.target.value)} placeholder={setting.hasToken ? "已配置(输入新 Token 可替换)" : "输入 LibTV Token"} disabled={loading || saving || clearing} autoComplete="new-password" /> +
+
+ +
+ + {enabled ? "已启用" : "已停用"} +
+
+
+
+
+ + 测试连接 +
+
+ setTestUuid(event.target.value)} placeholder="输入可访问的 LibTV 画布 UUID" /> + +
+
用于验证当前 Token 是否可读取指定 LibTV 画布,不会向当前画布写入节点。
+
+
+ + + + ); +} diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx index 7f1ec22cc..6a8d5783d 100644 --- a/web/src/pages/canvas/canvas-project-top-bar.tsx +++ b/web/src/pages/canvas/canvas-project-top-bar.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Link } from "react-router"; -import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react"; +import { Bot, Check, ChevronDown, Clapperboard, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react"; import { Button, Dropdown, Modal, Tooltip } from "antd"; import { useWalletBalance } from "@/hooks/use-wallet-balance"; @@ -28,6 +28,7 @@ type CanvasTopBarProps = { onCreateProject: () => void; onDeleteProject: () => void; onImportImage: () => void; + onImportLibTV: () => void; onUndo: () => void; onRedo: () => void; onShare: () => void; @@ -58,6 +59,7 @@ export function CanvasTopBar({ onCreateProject, onDeleteProject, onImportImage, + onImportLibTV, onUndo, onRedo, onShare, @@ -191,6 +193,9 @@ export function CanvasTopBar({
void; + onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise; +}; + +function buildCanvasNodes(result: LibTVImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) { + const minX = Math.min(...result.nodes.map((node) => node.x)); + const minY = Math.min(...result.nodes.map((node) => node.y)); + const maxX = Math.max(...result.nodes.map((node) => node.x + node.width)); + const maxY = Math.max(...result.nodes.map((node) => node.y + node.height)); + const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2; + const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2; + return result.nodes.map((node) => ({ + id: node.id, + type: node.type === "video" ? CanvasNodeType.Video : CanvasNodeType.Image, + title: node.title, + position: { x: node.x + offsetX, y: node.y + offsetY }, + width: node.width, + height: node.height, + metadata: { + content: node.content, + previewContent: node.type === "image" ? buildLibTVImagePreviewUrl(node.content) : undefined, + prompt: node.prompt, + model: node.model, + status: node.status || "idle", + errorDetails: node.errorDetails, + naturalWidth: node.naturalWidth, + naturalHeight: node.naturalHeight, + durationMs: node.durationMs, + mimeType: node.mimeType, + importSource: node.metadata, + }, + })); +} + +export function LibTVImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) { + const { message } = App.useApp(); + const [value, setValue] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const uuid = useMemo(() => parseLibTVProjectUUID(value), [value]); + + const reset = () => { + setValue(""); + setResult(null); + }; + + const close = () => { + if (loading) return; + reset(); + onClose(); + }; + + const changeValue = (nextValue: string) => { + setValue(nextValue); + setResult(null); + }; + + const load = async () => { + if (!uuid) { + message.error("请填写 LibTV 画布 UUID 或链接"); + return; + } + setLoading(true); + try { + setResult(await importLibTVCanvas(projectId, uuid)); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取 LibTV 画布失败"); + } finally { + setLoading(false); + } + }; + + const apply = async () => { + if (!result) return; + setLoading(true); + try { + await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections); + reset(); + onClose(); + message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存导入结果失败"); + } finally { + setLoading(false); + } + }; + + return ( + + 关闭 + , + , + ] + ) : ( + + ) + } + > +
+
+ + changeValue(event.target.value)} + placeholder="粘贴 32 位 UUID、画布链接或分享链接" + disabled={loading} + suffix={uuid && value !== uuid ? : null} + /> +
+ {loading && !result ? ( +
+ + 正在读取 LibTV 画布… +
+ ) : null} + {result ? ( +
+
+
+
+
{result.projectName || "LibTV 画布"}
+
可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线
+
+ 批次:{formatLibTVBatchTime(result.batchCreatedAt)} +
+
节点会保留相对位置,并整体放到当前可视区域中心。
+
+ {result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.staleNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.convertedSpecialCount ? ( +
+ +
+ {result.skippedNodes.length ?
{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。
: null} + {!result.skippedNodes.length && result.skippedConnections.length ?
{result.skippedConnections.length} 条无效连线已自动忽略。
: null} + {result.reusedFailedNodeCount ?
{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。
: null} + {result.placeholderNodeCount ?
{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。
: null} + {result.convertedSpecialCount ?
{result.convertedSpecialCount} 个特殊节点已转换为图片参考节点。
: null} + {result.multiResultNodeCount ?
{result.multiResultNodeCount} 个多结果节点已使用首个结果。
: null} + {result.staleNodeCount ?
{result.staleNodeCount} 个过期标记节点仍保留现有结果。
: null} +
+
+ ) : null} +
+ 等待确认导入 +
+
+ ) : null} +
+
+ ); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index f8577b134..7869647fc 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -72,6 +72,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation"; import { CanvasTopBar } from "./canvas-project-top-bar"; +import { LibTVImportDialog } from "./components/libtv-import-dialog"; import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; import { CanvasProjectContextMenu } from "./canvas-project-context-menu"; import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; @@ -210,6 +211,7 @@ function InfiniteCanvasPage() { const [scriptScrollTopById, setScriptScrollTopById] = useState>({}); const [directorNodeId, setDirectorNodeId] = useState(null); const [versionCompareRootId, setVersionCompareRootId] = useState(null); + const [libTVImportOpen, setLibTVImportOpen] = useState(false); const codexAutoConnect = shouldAutoConnectCanvasRuntime(searchParams); const codexCompactAgent = codexAutoConnect && readLocalRuntimeBootstrapState().legacyDeepLinkRejected; const [titleEditing, setTitleEditing] = useState(false); @@ -331,6 +333,28 @@ function InfiniteCanvasPage() { cleanupAssetImages, cleanupCanvasFiles, }); + + const applyLibTVImport = useCallback( + async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => { + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + const nextNodes = [...nodesRef.current, ...importedNodes]; + const nextConnections = [...connectionsRef.current, ...importedConnections]; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + const saved = await saveCanvasProject(); + if (!saved) { + nodesRef.current = previousNodes; + connectionsRef.current = previousConnections; + setNodes(previousNodes); + setConnections(previousConnections); + throw new Error("画布保存失败,已撤销本次 LibTV 导入"); + } + }, + [saveCanvasProject, setConnections, setNodes], + ); const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : ""; const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) }); const refetchLinkedProject = linkedProjectQuery.refetch; @@ -1639,6 +1663,7 @@ function InfiniteCanvasPage() { onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} onImportImage={() => handleUploadRequest()} + onImportLibTV={() => setLibTVImportOpen(true)} onUndo={undoCanvas} onRedo={redoCanvas} onShare={() => setShareModalOpen(true)} @@ -1686,6 +1711,7 @@ function InfiniteCanvasPage() { ) : null} setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> + setLibTVImportOpen(false)} onApply={applyLibTVImport} /> setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index 7ba15dd93..73666f34e 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -187,7 +187,7 @@ export function useCanvasProjectLifecycle({ renameProject(projectId, title); }, [projectId, renameProject]); - const saveCanvasProject = useCallback(async () => { + const saveCanvasProject = useCallback(async (): Promise => { try { updateProject(projectId, { nodes: nodesRef.current, @@ -202,7 +202,7 @@ export function useCanvasProjectLifecycle({ await flushCanvasStorePersistence(); } catch { message.error("画布保存失败,请稍后重试"); - return; + return false; } try { await saveRemoteUserDataNow(); @@ -211,6 +211,7 @@ export function useCanvasProjectLifecycle({ const detail = error instanceof Error ? error.message : "未知错误"; message.warning(`本地画布布局已保存,云端同步失败:${detail}`); } + return true; }, [activeChatId, backgroundMode, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); const clearCanvasFiles = useCallback(() => { diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index a7344ac6b..bd8842928 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -109,20 +109,23 @@ export function useCanvasRenderModel({ }); return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) }; }, [nodes]); - const visibleNodes = useMemo(() => { + const renderBounds = useMemo(() => { const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k; const viewLeft = -viewport.x / viewport.k - padding; const viewTop = -viewport.y / viewport.k - padding; const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2; const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2; + return { left: viewLeft, top: viewTop, right: viewRight, bottom: viewBottom }; + }, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]); + const visibleNodes = useMemo(() => { const frames: CanvasNodeData[] = []; const regular: CanvasNodeData[] = []; nodes.forEach((node) => { - if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= viewLeft || node.position.x >= viewRight || node.position.y + node.height <= viewTop || node.position.y >= viewBottom) return; + if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom) return; (isFrameNode(node) ? frames : regular).push(node); }); return [...frames, ...regular]; - }, [nodes, reduceMediaEffects, renderHiddenNodeIds, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]); + }, [nodes, renderBounds, renderHiddenNodeIds]); const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]); const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]); @@ -228,8 +231,13 @@ export function useCanvasRenderModel({ if (displayFrom.id === displayTo.id) return []; const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom; const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo; + const connectionLeft = Math.min(from.position.x, to.position.x); + const connectionTop = Math.min(from.position.y, to.position.y); + const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width); + const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height); + if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return []; return [{ connection, from, to }]; - }), [collapsedBatchChildIds, connections, dragPreview, nodeById]); + }), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]); const configInputsById = useMemo(() => { const map = new Map(); diff --git a/web/src/pages/projects/detail/assets.tsx b/web/src/pages/projects/detail/assets.tsx index 79ba759a5..5e7963c94 100644 --- a/web/src/pages/projects/detail/assets.tsx +++ b/web/src/pages/projects/detail/assets.tsx @@ -5,6 +5,7 @@ import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2, import { WorkspaceState } from "@/components/layout/workspace-state"; import { AssetMediaPreview } from "@/components/asset-media-preview"; +import { CachedResourceImage } from "@/components/cached-resource-image"; import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card"; import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal"; @@ -296,7 +297,7 @@ function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, o {asset?.category === "character" ? : null}{canDownload ? : null}
}> {asset ?
- {asset.category === "character" ? characterCover ? {asset.title} :
: personalAsset?.kind === "video" ?
} /> :
: personalAsset?.kind === "video" ?