diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ecee85a18..b10d3b60c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -80,7 +80,6 @@ func main() { handler.RegisterAdminAnalyticsRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) - handler.RegisterTapNowRoutes(api, svc) // 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。 handler.RegisterChannelModelRoutes(api, svc) handler.RegisterSystemProxyRoutes(api, svc) diff --git a/backend/internal/handler/tapnow.go b/backend/internal/handler/tapnow.go deleted file mode 100644 index 99a7cc263..000000000 --- a/backend/internal/handler/tapnow.go +++ /dev/null @@ -1,36 +0,0 @@ -package handler - -import ( - "net/http" - "strings" - - "infinite-canvas/backend/internal/service" - - "github.com/gin-gonic/gin" -) - -func RegisterTapNowRoutes(r *gin.RouterGroup, svc *service.Service) { - r.POST("/canvas-projects/:id/import/tapnow", 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.TapNowImportRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.ImportTapNow(user.ID, c.Param("id"), strings.TrimSpace(req.ShareID)) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) -} diff --git a/backend/internal/service/tapnow.go b/backend/internal/service/tapnow.go deleted file mode 100644 index 5665ebc2d..000000000 --- a/backend/internal/service/tapnow.go +++ /dev/null @@ -1,222 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -const ( - tapNowShareURLPrefix = "https://app.tapnow.media/api/conversation/v1/canvas-share/" - tapNowMaxResponseBytes = 16 << 20 - tapNowMaxNodes = 1000 - tapNowMaxConnections = 5000 -) - -type TapNowImportRequest struct { - ShareID string `json:"shareId"` -} - -type TapNowImportResult struct { - BatchID string `json:"batchId"` - BatchCreatedAt time.Time `json:"batchCreatedAt"` - ShareID string `json:"shareId"` - ProjectName string `json:"projectName"` - Nodes []TapNowCanvasNode `json:"nodes"` - Connections []TapNowCanvasConnection `json:"connections"` - ImportedNodeCount int `json:"importedNodeCount"` - ImportedConnectionCount int `json:"importedConnectionCount"` - SkippedNodes []TapNowImportIssue `json:"skippedNodes"` - SkippedConnections []TapNowImportIssue `json:"skippedConnections"` - Warnings []TapNowImportWarning `json:"warnings"` - MultiResultNodeCount int `json:"multiResultNodeCount"` - ReusedFailedNodeCount int `json:"reusedFailedNodeCount"` - PlaceholderNodeCount int `json:"placeholderNodeCount"` -} - -type TapNowCanvasNode 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"` - Size string `json:"size,omitempty"` - Quality string `json:"quality,omitempty"` - Seconds string `json:"seconds,omitempty"` - VQuality string `json:"vquality,omitempty"` - GenerateAudio string `json:"generateAudio,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 TapNowImportMetadata `json:"metadata"` -} - -type TapNowImportMetadata struct { - Provider string `json:"provider"` - ShareID string `json:"shareId"` - NodeID string `json:"nodeId"` - BatchID string `json:"batchId"` - SourceType string `json:"sourceType,omitempty"` -} - -type TapNowCanvasConnection struct { - ID string `json:"id"` - FromNodeID string `json:"fromNodeId"` - ToNodeID string `json:"toNodeId"` - FromHandleID string `json:"fromHandleId,omitempty"` - ToHandleID string `json:"toHandleId,omitempty"` -} - -type TapNowImportIssue struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Reason string `json:"reason"` -} - -type TapNowImportWarning struct { - ID string `json:"id,omitempty"` - Message string `json:"message"` -} - -type tapNowEnvelope struct { - Code int `json:"code"` - Data json.RawMessage `json:"data"` - Msg string `json:"msg"` - Message string `json:"message"` -} - -type tapNowDetail struct { - ID string `json:"id"` - Name string `json:"name"` - Nodes []tapNowRawNode `json:"nodes"` - Connections []tapNowRawConnection `json:"connections"` -} - -type tapNowRawNode struct { - ID string `json:"id"` - ShortID int64 `json:"short_id"` - Type string `json:"type"` - Data json.RawMessage `json:"data"` - DeletedAt string `json:"deleted_at"` - Position struct { - X float64 `json:"x"` - Y float64 `json:"y"` - } `json:"position"` - Measured struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - } `json:"measured"` -} - -type tapNowRawConnection struct { - ID string `json:"id"` - Source string `json:"source"` - Target string `json:"target"` - SourceHandle string `json:"sourceHandle"` - TargetHandle string `json:"targetHandle"` - Data map[string]any `json:"data"` - DeletedAt string `json:"deleted_at"` -} - -type tapNowNodeData struct { - Title string `json:"title"` - Prompt string `json:"prompt"` - Text string `json:"text"` - Src string `json:"src"` - Options []string `json:"options"` - Type string `json:"type"` - Params map[string]any `json:"params"` - TaskInfo map[string]any `json:"taskInfo"` - Metadata struct { - URL string `json:"url"` - Width int `json:"width"` - Height int `json:"height"` - Duration float64 `json:"duration"` - } `json:"__metadata"` - Error string `json:"error"` - FailedReason string `json:"failedReason"` -} - -// 读取公开分享前先校验目标画布归属,避免把导入结果写入他人画布。 -func (s *Service) ImportTapNow(userID, canvasProjectID, shareID string) (*TapNowImportResult, 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 - } - detail, normalizedShareID, err := fetchTapNowDetail(strings.TrimSpace(shareID)) - if err != nil { - return nil, err - } - return adaptTapNowDetail(detail, normalizedShareID) -} - -func fetchTapNowDetail(shareID string) (*tapNowDetail, string, error) { - if !isTapNowShareID(shareID) { - return nil, "", BadAuthRequest("TapNow 分享 ID 格式无效") - } - endpoint, err := url.Parse(tapNowShareURLPrefix + url.PathEscape(shareID)) - if err != nil { - return nil, "", err - } - req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) - if err != nil { - return nil, "", err - } - req.Header.Set("Accept", "application/json") - ApplyDefaultOutboundHeaders(req) - client := OutboundHTTPClient(20 * time.Second) - client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { - return errors.New("TapNow API 不允许重定向") - } - resp, err := client.Do(req) - if err != nil { - return nil, "", errors.New("TapNow 请求失败,请检查网络或分享链接") - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, tapNowMaxResponseBytes+1)) - if err != nil { - return nil, "", errors.New("读取 TapNow 响应失败") - } - if len(body) > tapNowMaxResponseBytes { - return nil, "", errors.New("TapNow 画布响应过大") - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, "", fmt.Errorf("TapNow 请求失败(HTTP %d)", resp.StatusCode) - } - var envelope tapNowEnvelope - if err := json.Unmarshal(body, &envelope); err != nil { - return nil, "", errors.New("TapNow 响应格式无效") - } - if envelope.Code != 0 { - msg := strings.TrimSpace(envelope.Msg) - if msg == "" { - msg = strings.TrimSpace(envelope.Message) - } - if msg == "" { - msg = "TapNow 返回业务错误" - } - return nil, "", errors.New(msg) - } - var detail tapNowDetail - if err := json.Unmarshal(envelope.Data, &detail); err != nil { - return nil, "", errors.New("TapNow 画布数据格式无效") - } - return &detail, shareID, nil -} diff --git a/backend/internal/service/tapnow_adapter.go b/backend/internal/service/tapnow_adapter.go deleted file mode 100644 index 7bf6ee8b1..000000000 --- a/backend/internal/service/tapnow_adapter.go +++ /dev/null @@ -1,407 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "math" - "net/url" - "strconv" - "strings" - "time" - - "github.com/google/uuid" -) - -func adaptTapNowDetail(detail *tapNowDetail, shareID string) (*TapNowImportResult, error) { - if detail == nil { - return nil, errors.New("TapNow 画布数据为空") - } - shareID = strings.TrimSpace(shareID) - if !isTapNowShareID(shareID) { - return nil, BadAuthRequest("TapNow 分享 ID 格式无效") - } - if len(detail.Nodes) > tapNowMaxNodes { - return nil, fmt.Errorf("TapNow 画布节点过多(最多支持 %d 个)", tapNowMaxNodes) - } - - now := time.Now() - batchID := "tapnow-" + strconv.FormatInt(now.UnixMilli(), 36) + "-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8] - result := &TapNowImportResult{ - BatchID: batchID, - BatchCreatedAt: now, - ShareID: shareID, - ProjectName: strings.TrimSpace(detail.Name), - Nodes: make([]TapNowCanvasNode, 0, len(detail.Nodes)), - Connections: make([]TapNowCanvasConnection, 0, minInt(len(detail.Connections), tapNowMaxConnections)), - SkippedNodes: make([]TapNowImportIssue, 0), - SkippedConnections: make([]TapNowImportIssue, 0), - Warnings: make([]TapNowImportWarning, 0), - } - if result.ProjectName == "" { - result.ProjectName = "TapNow 画布" - } - - mapping := make(map[string]string, len(detail.Nodes)) - seenNodeIDs := make(map[string]struct{}, len(detail.Nodes)) - for _, raw := range detail.Nodes { - if strings.TrimSpace(raw.DeletedAt) != "" { - continue - } - nodeID := strings.TrimSpace(raw.ID) - if nodeID == "" && raw.ShortID != 0 { - nodeID = strconv.FormatInt(raw.ShortID, 10) - } - if nodeID == "" { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{Reason: "节点缺少 ID"}) - continue - } - if len(nodeID) > 128 { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID[:128], Reason: "节点 ID 过长"}) - continue - } - if _, exists := seenNodeIDs[nodeID]; exists { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点 ID 重复"}) - continue - } - seenNodeIDs[nodeID] = struct{}{} - - data, err := decodeTapNowNodeData(raw.Data) - if err != nil { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点数据格式无效"}) - continue - } - kind := strings.ToLower(strings.TrimSpace(raw.Type)) - if kind == "" { - kind = strings.ToLower(strings.TrimSpace(data.Type)) - } - if kind != "image" && kind != "video" && kind != "audio" && kind != "text" { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "暂不支持的节点类型"}) - continue - } - if !finiteNumber(raw.Position.X) || !finiteNumber(raw.Position.Y) { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "节点坐标无效"}) - continue - } - - content, mediaIndex := firstTapNowMediaURL(data) - if kind == "text" { - content = strings.TrimSpace(data.Text) - if content == "" { - content = strings.TrimSpace(data.Prompt) - } - mediaIndex = -1 - } - width, height := raw.Measured.Width, raw.Measured.Height - if !positiveFiniteNumber(width) { - width = 480 - } - if !positiveFiniteNumber(height) { - height = 300 - } - item := TapNowCanvasNode{ - ID: "tapnow-" + batchID + "-" + nodeID, - Type: kind, - Title: strings.TrimSpace(data.Title), - X: raw.Position.X, - Y: raw.Position.Y, - Width: width, - Height: height, - Content: content, - Prompt: firstNonEmptyString(strings.TrimSpace(data.Prompt), firstTapNowParam(data.Params, "prompt")), - Model: tapNowMapString(data.Params, "model"), - Size: firstTapNowParam(data.Params, "aspectRatio", "size"), - Quality: tapNowImageQuality(data.Params), - Seconds: firstTapNowParam(data.Params, "duration", "seconds"), - VQuality: firstTapNowParam(data.Params, "resolution", "vquality"), - GenerateAudio: tapNowBoolParam(data.Params, "generateAudio"), - Metadata: TapNowImportMetadata{ - Provider: "tapnow", - ShareID: shareID, - NodeID: nodeID, - BatchID: batchID, - SourceType: strings.TrimSpace(data.Type), - }, - } - if item.Title == "" { - item.Title = kind + " 节点" - } - item.NaturalWidth = data.Metadata.Width - item.NaturalHeight = data.Metadata.Height - if data.Metadata.Duration > 0 && !math.IsNaN(data.Metadata.Duration) && !math.IsInf(data.Metadata.Duration, 0) { - item.DurationMs = int64(math.Round(data.Metadata.Duration * 1000)) - } - item.MimeType = inferTapNowMime(kind, content, firstNonEmptyString(data.Prompt, data.Title)) - taskStatus := tapNowTaskStatus(data.TaskInfo) - hasContent := strings.TrimSpace(content) != "" - if hasContent { - item.Status = "success" - if tapNowTaskFailed(taskStatus) { - result.ReusedFailedNodeCount++ - } - } else if tapNowTaskFailed(taskStatus) { - item.Status = "error" - item.ErrorDetails = tapNowErrorDetails(data) - if item.ErrorDetails == "" { - item.ErrorDetails = "TapNow 生成任务失败" - } - result.PlaceholderNodeCount++ - } else { - item.Status = "idle" - result.PlaceholderNodeCount++ - } - mapping[nodeID] = item.ID - if raw.ShortID != 0 { - mapping[strconv.FormatInt(raw.ShortID, 10)] = item.ID - } - result.Nodes = append(result.Nodes, item) - if !positiveFiniteNumber(raw.Measured.Width) || !positiveFiniteNumber(raw.Measured.Height) { - result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”尺寸无效,已使用默认尺寸。", item.Title)}) - } - if mediaIndex >= 0 && len(data.Options) > 1 { - result.MultiResultNodeCount++ - result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”包含多个结果,已导入首个结果。", item.Title)}) - } - } - - if len(detail.Connections) > tapNowMaxConnections { - result.Warnings = append(result.Warnings, TapNowImportWarning{Message: fmt.Sprintf("连接超过 %d 条,仅处理前 %d 条。", tapNowMaxConnections, tapNowMaxConnections)}) - } - seenConnectionIDs := make(map[string]struct{}, len(detail.Connections)) - for index, raw := range detail.Connections { - if index >= tapNowMaxConnections { - break - } - if strings.TrimSpace(raw.DeletedAt) != "" { - continue - } - connectionID := strings.TrimSpace(raw.ID) - if connectionID == "" { - result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{Reason: "连接缺少 ID"}) - continue - } - if _, exists := seenConnectionIDs[connectionID]; exists { - result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接 ID 重复"}) - 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, TapNowImportIssue{ID: connectionID, Reason: "连接端点节点未导入"}) - continue - } - fromHandleID := strings.TrimSpace(raw.SourceHandle) - toHandleID := strings.TrimSpace(raw.TargetHandle) - if toHandleID == "" { - // TapNow 将图片输入句柄放在边 data.valueKey,而不是 targetHandle。 - toHandleID = tapNowMapString(raw.Data, "valueKey") - } - result.Connections = append(result.Connections, TapNowCanvasConnection{ - ID: "tapnow-" + batchID + "-" + connectionID, - FromNodeID: from, - ToNodeID: to, - FromHandleID: fromHandleID, - ToHandleID: toHandleID, - }) - } - result.ImportedNodeCount = len(result.Nodes) - result.ImportedConnectionCount = len(result.Connections) - if result.ImportedNodeCount == 0 { - return nil, errors.New("TapNow 画布没有可导入的有效节点") - } - return result, nil -} - -func decodeTapNowNodeData(raw json.RawMessage) (tapNowNodeData, error) { - var data tapNowNodeData - trimmed := strings.TrimSpace(string(raw)) - if strings.HasPrefix(trimmed, `"`) { - var encoded string - if err := json.Unmarshal(raw, &encoded); err != nil { - return data, err - } - raw = json.RawMessage(encoded) - } - if err := json.Unmarshal(raw, &data); err != nil { - return data, err - } - return data, nil -} - -func firstTapNowMediaURL(data tapNowNodeData) (string, int) { - values := make([]string, 0, len(data.Options)+2) - values = append(values, data.Src) - values = append(values, data.Options...) - values = append(values, data.Metadata.URL) - for index, value := range values { - parsed, err := url.Parse(strings.TrimSpace(value)) - if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || !isTapNowMediaHost(parsed.Hostname()) { - continue - } - return parsed.String(), index - } - return "", -1 -} - -func isTapNowMediaHost(host string) bool { - host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") - // 导入后的媒体还可能作为后续生成的参考素材,只接受已核验的官方文件域名, - // 不能根据品牌前缀放行未经确认的同名域,避免把外部 URL 带入后端出网链路。 - return host == "files.tapnow.media" -} - -func inferTapNowMime(kind, rawURL, hint string) string { - lower := strings.ToLower(rawURL + " " + hint) - switch kind { - case "image": - switch { - case strings.Contains(lower, ".png"): - return "image/png" - case strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg"): - return "image/jpeg" - case strings.Contains(lower, ".webp"): - return "image/webp" - case strings.Contains(lower, ".gif"): - return "image/gif" - default: - return "image/*" - } - case "video": - switch { - case strings.Contains(lower, ".webm"): - return "video/webm" - case strings.Contains(lower, ".mov"): - return "video/quicktime" - default: - return "video/mp4" - } - case "audio": - switch { - case strings.Contains(lower, ".wav"): - return "audio/wav" - case strings.Contains(lower, ".flac"): - return "audio/flac" - case strings.Contains(lower, ".ogg") || strings.Contains(lower, ".oga"): - return "audio/ogg" - case strings.Contains(lower, ".m4a"): - return "audio/mp4" - default: - return "audio/mpeg" - } - case "text": - return "text/plain" - default: - return "application/octet-stream" - } -} - -func tapNowMapString(values map[string]any, key string) string { - if values == nil { - return "" - } - value, ok := values[key] - if !ok { - return "" - } - if value == nil { - return "" - } - if stringValue, ok := value.(string); ok { - return strings.TrimSpace(stringValue) - } - return strings.TrimSpace(fmt.Sprint(value)) -} - -func firstTapNowParam(values map[string]any, keys ...string) string { - for _, key := range keys { - if value := tapNowMapString(values, key); value != "" { - return value - } - } - return "" -} - -// TapNow 部分渠道用 imageSize(例如 "2K"),画布合同则使用小写质量档位。 -// 只迁移已知档位,避免把渠道私有值静默写成无效的本地质量设置。 -func tapNowImageQuality(values map[string]any) string { - value := firstTapNowParam(values, "quality") - if value == "" { - value = firstTapNowParam(values, "imageSize") - } - normalized := strings.ToLower(strings.TrimSpace(value)) - switch normalized { - case "1k", "2k", "4k", "auto", "low", "medium", "high": - return normalized - default: - return "" - } -} - -func tapNowBoolParam(values map[string]any, key string) string { - value, ok := values[key] - if !ok { - return "" - } - if boolean, ok := value.(bool); ok { - return strconv.FormatBool(boolean) - } - return tapNowMapString(values, key) -} - -func tapNowTaskStatus(values map[string]any) string { - status := strings.ToLower(strings.TrimSpace(tapNowMapString(values, "status"))) - return status -} - -func tapNowTaskFailed(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "failed", "error", "failure": - return true - default: - return false - } -} - -func tapNowErrorDetails(data tapNowNodeData) string { - if value := strings.TrimSpace(data.Error); value != "" { - return value - } - if value := strings.TrimSpace(data.FailedReason); value != "" { - return value - } - for _, key := range []string{"error", "failedReason", "message"} { - if value := tapNowMapString(data.TaskInfo, key); value != "" { - return value - } - } - return "" -} - -func isTapNowShareID(value string) bool { - value = strings.TrimSpace(value) - if value == "" || len(value) > 64 { - return false - } - for _, char := range value { - if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-') { - return false - } - } - return true -} - -func finiteNumber(value float64) bool { - return !math.IsNaN(value) && !math.IsInf(value, 0) -} - -func positiveFiniteNumber(value float64) bool { - return finiteNumber(value) && value > 0 -} - -func minInt(left, right int) int { - if left < right { - return left - } - return right -} diff --git a/backend/internal/service/tapnow_test.go b/backend/internal/service/tapnow_test.go deleted file mode 100644 index 9c80f707a..000000000 --- a/backend/internal/service/tapnow_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package service - -import "testing" - -func TestAdaptTapNowDetailSupportsMediaAndTextNodes(t *testing.T) { - detail := &tapNowDetail{ - Name: "TapNow 测试画布", - Nodes: []tapNowRawNode{ - {ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://files.tapnow.media/api/conversation/storage/uploads/image.png","prompt":"画面提示词","params":{"model":"gpt-image-2","imageSize":"2K"},"__metadata":{"width":1024,"height":768}}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 10, Y: 20}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 448, Height: 250}}, - {ID: "video-1", Type: "video", Data: []byte(`{"title":"视频","type":"generate","src":"","taskInfo":{"status":"failed","failedReason":"上游失败"},"params":{"model":"seedance-2.0","aspectRatio":"16:9","duration":8,"resolution":"720p","generateAudio":true},"__metadata":{"width":1280,"height":720,"duration":4.5}}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 500, Y: 20}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 444, Height: 250}}, - {ID: "audio-1", Type: "audio", Data: []byte(`{"title":"音频","type":"upload","src":"https://files.tapnow.media/api/conversation/storage/uploads/audio-file","prompt":"就这样.wav"}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 10, Y: 400}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 444, Height: 250}}, - {ID: "text-1", Type: "text", Data: []byte(`{"title":"文本","type":"generate","text":"故事文本"}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 500, Y: 400}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 300, Height: 200}}, - }, - Connections: []tapNowRawConnection{{ID: "edge-1", Source: "image-1", Target: "video-1", SourceHandle: "images", TargetHandle: "prompt"}}, - } - - result, err := adaptTapNowDetail(detail, "8872a294") - if err != nil { - t.Fatalf("adaptTapNowDetail() error = %v", err) - } - if result.ImportedNodeCount != 4 || result.ImportedConnectionCount != 1 { - t.Fatalf("counts = (%d, %d), want (4, 1)", result.ImportedNodeCount, result.ImportedConnectionCount) - } - if result.Nodes[0].Metadata.Provider != "tapnow" || result.Nodes[0].Model != "gpt-image-2" || result.Nodes[0].Quality != "2k" || result.Nodes[0].NaturalWidth != 1024 { - t.Fatalf("image mapping = %#v", result.Nodes[0]) - } - if result.Nodes[1].Status != "error" || result.Nodes[1].ErrorDetails != "上游失败" || result.PlaceholderNodeCount != 1 { - t.Fatalf("failed video mapping = %#v", result.Nodes[1]) - } - if result.Nodes[1].Size != "16:9" || result.Nodes[1].Seconds != "8" || result.Nodes[1].VQuality != "720p" || result.Nodes[1].GenerateAudio != "true" { - t.Fatalf("video generation params = %#v", result.Nodes[1]) - } - if result.Nodes[2].MimeType != "audio/wav" || result.Nodes[3].Type != "text" || result.Nodes[3].Content != "故事文本" { - t.Fatalf("audio/text mapping = %#v %#v", result.Nodes[2], result.Nodes[3]) - } - if result.Connections[0].FromHandleID != "images" || result.Connections[0].ToHandleID != "prompt" { - t.Fatalf("connection handles = %#v", result.Connections[0]) - } -} - -func TestAdaptTapNowDetailUsesValueKeyAsTargetHandle(t *testing.T) { - detail := &tapNowDetail{ - Nodes: []tapNowRawNode{ - {ID: "source", Type: "image", Data: []byte(`{"title":"源图","src":"https://files.tapnow.media/source"}`)}, - {ID: "target", Type: "image", Data: []byte(`{"title":"目标图","src":"https://files.tapnow.media/target"}`)}, - }, - Connections: []tapNowRawConnection{{ID: "edge", Source: "source", Target: "target", Data: map[string]any{"valueKey": "images"}}}, - } - result, err := adaptTapNowDetail(detail, "8872a294") - if err != nil { - t.Fatalf("adaptTapNowDetail() error = %v", err) - } - if got := result.Connections[0].ToHandleID; got != "images" { - t.Fatalf("target handle = %q, want images", got) - } -} - -func TestAdaptTapNowDetailRejectsUntrustedMediaAndKeepsPlaceholder(t *testing.T) { - detail := &tapNowDetail{Nodes: []tapNowRawNode{{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://evil.example/image.png"}`)}}} - result, err := adaptTapNowDetail(detail, "share_1") - if err != nil { - t.Fatalf("adaptTapNowDetail() error = %v", err) - } - if result.Nodes[0].Content != "" || result.Nodes[0].Status != "idle" || result.PlaceholderNodeCount != 1 { - t.Fatalf("untrusted media handling = %#v", result.Nodes[0]) - } -} diff --git a/web/package.json b/web/package.json index 9d0dcde1d..5485d69c3 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/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.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/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/lib/canvas/tapnow-import.ts b/web/src/lib/canvas/tapnow-import.ts deleted file mode 100644 index 26904fa48..000000000 --- a/web/src/lib/canvas/tapnow-import.ts +++ /dev/null @@ -1,25 +0,0 @@ -const TAPNOW_SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const TAPNOW_SHARE_HOST = "app.tapnow.media"; - -export function parseTapNowShareID(value: string) { - const trimmed = value.trim(); - if (!trimmed) return ""; - let candidate = trimmed; - try { - const parsed = new URL(trimmed); - const hostname = parsed.hostname.toLowerCase().replace(/\.$/, ""); - if (hostname !== TAPNOW_SHARE_HOST || !/^\/tapflow\/view\/?[^/]*\/?$/.test(parsed.pathname)) return ""; - const parts = parsed.pathname.split("/").filter(Boolean); - candidate = parts.at(-1) || ""; - } catch { - candidate = trimmed; - } - return TAPNOW_SHARE_ID_PATTERN.test(candidate) ? candidate : ""; -} - -export function formatTapNowBatchTime(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "未知"; - const pad = (part: number) => String(part).padStart(2, "0"); - return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}`; -} diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx index 798065413..7f1ec22cc 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, CloudDownload, 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, 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,7 +28,6 @@ type CanvasTopBarProps = { onCreateProject: () => void; onDeleteProject: () => void; onImportImage: () => void; - onImportTapNow: () => void; onUndo: () => void; onRedo: () => void; onShare: () => void; @@ -59,7 +58,6 @@ export function CanvasTopBar({ onCreateProject, onDeleteProject, onImportImage, - onImportTapNow, onUndo, onRedo, onShare, @@ -193,9 +191,6 @@ export function CanvasTopBar({
void; - onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise; -}; - -function buildCanvasNodes(result: TapNowImportResult, 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 : node.type === "audio" ? CanvasNodeType.Audio : node.type === "text" ? CanvasNodeType.Text : CanvasNodeType.Image, - title: node.title, - position: { x: node.x + offsetX, y: node.y + offsetY }, - width: node.width, - height: node.height, - metadata: { - content: node.content, - prompt: node.prompt, - model: node.model, - size: node.size, - quality: node.quality, - seconds: node.seconds, - vquality: node.vquality, - generateAudio: node.generateAudio, - status: node.status || "idle", - errorDetails: node.errorDetails, - naturalWidth: node.naturalWidth, - naturalHeight: node.naturalHeight, - durationMs: node.durationMs, - mimeType: node.mimeType, - importSource: node.metadata, - }, - })); -} - -export function TapNowImportDialog({ 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 shareID = useMemo(() => parseTapNowShareID(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 (!shareID) { - message.error("请填写有效的 TapNow 画布分享链接或分享 ID"); - return; - } - setLoading(true); - try { - setResult(await importTapNowCanvas(projectId, shareID)); - } catch (error) { - message.error(error instanceof Error ? error.message : "读取 TapNow 画布失败"); - } 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); - } - }; - - const hasWarnings = result ? Boolean(result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.warnings.length) : false; - - return ( - - 关闭 - , - , - ] - ) : ( - - ) - } - > -
-
- - changeValue(event.target.value)} - placeholder="粘贴 https://app.tapnow.media/tapflow/view/…" - disabled={loading} - suffix={shareID && value !== shareID ? : null} - /> -
- {loading && !result ? ( -
- - 正在读取 TapNow 画布… -
- ) : null} - {result ? ( -
-
-
-
-
{result.projectName || "TapNow 画布"}
-
- 可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线 -
-
- 批次:{formatTapNowBatchTime(result.batchCreatedAt)} -
-
支持图片、视频、音频和文本节点;节点会保留相对位置,并整体放到当前可视区域中心。
-
- {hasWarnings ? ( -
- -
- {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.multiResultNodeCount ?
{result.multiResultNodeCount} 个多结果节点已使用首个结果。
: null} - {result.warnings.map((warning, index) => ( -
{warning.message}
- ))} -
-
- ) : null} -
- 等待确认导入 -
-
- ) : null} -
-
- ); -} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index b7766439c..68cce559c 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -72,7 +72,6 @@ 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 { TapNowImportDialog } from "./components/tapnow-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"; @@ -191,7 +190,6 @@ function InfiniteCanvasPage() { const [workspaceMode, setWorkspaceMode] = useState(readCanvasWorkspaceMode); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false); - const [tapNowImportOpen, setTapNowImportOpen] = useState(false); const [nodeSearchOpen, setNodeSearchOpen] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); @@ -333,28 +331,6 @@ function InfiniteCanvasPage() { cleanupAssetImages, cleanupCanvasFiles, }); - - const applyTapNowImport = 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("画布保存失败,已撤销本次 TapNow 导入"); - } - }, - [saveCanvasProject, setConnections, setNodes], - ); const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : ""; const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) }); const refetchLinkedProject = linkedProjectQuery.refetch; @@ -1663,7 +1639,6 @@ function InfiniteCanvasPage() { onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} onImportImage={() => handleUploadRequest()} - onImportTapNow={() => setTapNowImportOpen(true)} onUndo={undoCanvas} onRedo={redoCanvas} onShare={() => setShareModalOpen(true)} @@ -1711,7 +1686,6 @@ function InfiniteCanvasPage() { ) : null} setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> - setTapNowImportOpen(false)} onApply={applyTapNowImport} /> setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> diff --git a/web/src/services/api/tapnow.ts b/web/src/services/api/tapnow.ts deleted file mode 100644 index 0799ae57d..000000000 --- a/web/src/services/api/tapnow.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { CanvasConnection } from "@/types/canvas"; -import { apiClient, request } from "@/services/api/request"; - -export type TapNowImportIssue = { id?: string; name?: string; reason: string }; -export type TapNowImportWarning = { id?: string; message: string }; - -export type TapNowImportNode = { - id: string; - type: "image" | "video" | "audio" | "text"; - title: string; - x: number; - y: number; - width: number; - height: number; - content: string; - prompt?: string; - model?: string; - size?: string; - quality?: string; - seconds?: string; - vquality?: string; - generateAudio?: string; - naturalWidth?: number; - naturalHeight?: number; - durationMs?: number; - mimeType?: string; - status?: "idle" | "success" | "error"; - errorDetails?: string; - metadata: { - provider: "tapnow"; - shareId: string; - nodeId: string; - batchId: string; - sourceType?: string; - }; -}; - -export type TapNowImportResult = { - batchId: string; - batchCreatedAt: string; - shareId: string; - projectName: string; - nodes: TapNowImportNode[]; - connections: CanvasConnection[]; - importedNodeCount: number; - importedConnectionCount: number; - skippedNodes: TapNowImportIssue[]; - skippedConnections: TapNowImportIssue[]; - warnings: TapNowImportWarning[]; - multiResultNodeCount: number; - reusedFailedNodeCount: number; - placeholderNodeCount: number; -}; - -export function importTapNowCanvas(projectId: string, shareId: string) { - return request(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/tapnow`, { shareId })); -} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 06fa09e74..43e9d5c0c 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -142,13 +142,6 @@ export type CanvasSkillSnapshot = { }; export type CanvasNodeMetadata = { - importSource?: { - provider: "tapnow"; - shareId: string; - nodeId: string; - batchId: string; - sourceType?: string; - }; content?: string; richText?: Record; composerContent?: string; diff --git a/web/test/tapnow-import.test.ts b/web/test/tapnow-import.test.ts deleted file mode 100644 index 6c2c510c6..000000000 --- a/web/test/tapnow-import.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { formatTapNowBatchTime, parseTapNowShareID } from "../src/lib/canvas/tapnow-import"; - -describe("TapNow canvas import helpers", () => { - test("extracts share IDs from TapNow links", () => { - expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/8872a294")).toBe("8872a294"); - expect(parseTapNowShareID("8872a294")).toBe("8872a294"); - expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/abc_DEF-12?source=share")).toBe("abc_DEF-12"); - }); - - test("rejects unrelated links and malformed IDs", () => { - expect(parseTapNowShareID("https://example.com/tapflow/view/8872a294")).toBe(""); - expect(parseTapNowShareID("https://app.tapnow.ai/tapflow/view/8872a294")).toBe(""); - expect(parseTapNowShareID("https://app.tapnow.media/canvas/8872a294")).toBe(""); - expect(parseTapNowShareID("bad/id")).toBe(""); - }); - - test("formats the readable batch label", () => { - const localTime = new Date(2026, 7, 19, 5, 2); - expect(formatTapNowBatchTime(localTime.toISOString())).toBe("202608190502"); - }); -});