feat(canvas): TapNow - 支持公开画布导入 (#255)

(cherry picked from commit 9b96135a5e409b4718e6204ab4212b7ef22635aa)
This commit is contained in:
kinoko
2026-08-19 11:34:23 +08:00
committed by GitHub
parent 60bad10fc9
commit 275c59a766
13 changed files with 1087 additions and 2 deletions

View File

@@ -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)

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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])
}
}

View File

@@ -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 ."

View File

@@ -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())}`;
}

View File

@@ -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({
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} onClick={onImportTapNow} aria-label="导入 TapNow 画布" title="导入 TapNow 画布">
<span className="hidden xl:inline">导入 TapNow</span>
</Button>
<Dropdown
trigger={["click"]}
menu={{

View File

@@ -0,0 +1,185 @@
import { App, Button, Input, Modal, Tag } from "antd";
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { formatTapNowBatchTime, parseTapNowShareID } from "@/lib/canvas/tapnow-import";
import { importTapNowCanvas, type TapNowImportResult } from "@/services/api/tapnow";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
type Props = {
open: boolean;
projectId: string;
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onClose: () => void;
onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise<void>;
};
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<CanvasNodeData>((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<TapNowImportResult | null>(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 (
<Modal
className="workspace-modal"
open={open}
onCancel={close}
title="导入 TapNow 画布"
width={620}
footer={
result ? (
[
<Button key="close" onClick={close}>
关闭
</Button>,
<Button key="apply" type="primary" icon={<Import className="size-4" />} loading={loading} onClick={() => void apply()}>
确认导入
</Button>,
]
) : (
<Button type="primary" loading={loading} onClick={() => void load()}>
读取画布
</Button>
)
}
>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">TapNow 画布分享链接或分享 ID</label>
<Input
value={value}
onChange={(event) => changeValue(event.target.value)}
placeholder="粘贴 https://app.tapnow.media/tapflow/view/…"
disabled={loading}
suffix={shareID && value !== shareID ? <ExternalLink className="size-4 text-foreground/35" /> : null}
/>
</div>
{loading && !result ? (
<div className="flex items-center gap-2 text-sm text-foreground/55">
<LoaderCircle className="size-4 animate-spin" />
正在读取 TapNow 画布…
</div>
) : null}
{result ? (
<div className="space-y-3">
<div className="rounded-xl p-4" style={{ background: "var(--library-surface)" }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{result.projectName || "TapNow 画布"}</div>
<div className="mt-1 text-sm text-foreground/60">
可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线
</div>
</div>
<Tag color="blue">批次:{formatTapNowBatchTime(result.batchCreatedAt)}</Tag>
</div>
<div className="mt-3 text-xs leading-5 text-foreground/50">支持图片、视频、音频和文本节点;节点会保留相对位置,并整体放到当前可视区域中心。</div>
</div>
{hasWarnings ? (
<div className="flex items-start gap-2 rounded-lg px-3 py-2.5 text-xs leading-5 text-foreground/60" style={{ background: "var(--surface-hover)" }}>
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<div>
{result.skippedNodes.length ? <div>{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。</div> : null}
{!result.skippedNodes.length && result.skippedConnections.length ? <div>{result.skippedConnections.length} 条无效连线已自动忽略。</div> : null}
{result.reusedFailedNodeCount ? <div>{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。</div> : null}
{result.placeholderNodeCount ? <div>{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。</div> : null}
{result.multiResultNodeCount ? <div>{result.multiResultNodeCount} 个多结果节点已使用首个结果。</div> : null}
{result.warnings.map((warning, index) => (
<div key={`${warning.id || "warning"}-${index}`}>{warning.message}</div>
))}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Tag>等待确认导入</Tag>
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -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 { 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";
@@ -190,6 +191,7 @@ function InfiniteCanvasPage() {
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(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<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
@@ -331,6 +333,28 @@ 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;
@@ -1639,6 +1663,7 @@ function InfiniteCanvasPage() {
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onImportTapNow={() => setTapNowImportOpen(true)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onShare={() => setShareModalOpen(true)}
@@ -1686,6 +1711,7 @@ function InfiniteCanvasPage() {
) : null}
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
<TapNowImportDialog open={tapNowImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setTapNowImportOpen(false)} onApply={applyTapNowImport} />
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />

View File

@@ -0,0 +1,57 @@
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<TapNowImportResult>(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/tapnow`, { shareId }));
}

View File

@@ -142,6 +142,13 @@ export type CanvasSkillSnapshot = {
};
export type CanvasNodeMetadata = {
importSource?: {
provider: "tapnow";
shareId: string;
nodeId: string;
batchId: string;
sourceType?: string;
};
content?: string;
richText?: Record<string, unknown>;
composerContent?: string;

View File

@@ -0,0 +1,23 @@
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");
});
});