diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b10d3b60c..ecee85a18 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -80,6 +80,7 @@ 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 new file mode 100644 index 000000000..99a7cc263 --- /dev/null +++ b/backend/internal/handler/tapnow.go @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..5665ebc2d --- /dev/null +++ b/backend/internal/service/tapnow.go @@ -0,0 +1,222 @@ +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 new file mode 100644 index 000000000..7bf6ee8b1 --- /dev/null +++ b/backend/internal/service/tapnow_adapter.go @@ -0,0 +1,407 @@ +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 new file mode 100644 index 000000000..9c80f707a --- /dev/null +++ b/backend/internal/service/tapnow_test.go @@ -0,0 +1,91 @@ +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 5485d69c3..9d0dcde1d 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 && 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 test/tapnow-import.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 new file mode 100644 index 000000000..26904fa48 --- /dev/null +++ b/web/src/lib/canvas/tapnow-import.ts @@ -0,0 +1,25 @@ +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 7f1ec22cc..798065413 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, CloudDownload, 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,6 +28,7 @@ type CanvasTopBarProps = { onCreateProject: () => void; onDeleteProject: () => void; onImportImage: () => void; + onImportTapNow: () => void; onUndo: () => void; onRedo: () => void; onShare: () => void; @@ -58,6 +59,7 @@ export function CanvasTopBar({ onCreateProject, onDeleteProject, onImportImage, + onImportTapNow, onUndo, onRedo, onShare, @@ -191,6 +193,9 @@ export function CanvasTopBar({