diff --git a/docs/pending-test.md b/docs/pending-test.md index fc2eb2b..bdb8a9b 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,6 +1,7 @@ # 待测试 - Docker 部署时,`DATABASE_DSN=data/infinite-canvas.db` 会在存在 `/app/data` 挂载目录时自动归一到 `/app/data/infinite-canvas.db`,需要验证后台模型配置不会再因为工作目录变为 `/app/web` 而读到空库。 +- Seedance 参考视频被火山判定包含真人或隐私信息时,前端错误摘要会提示改用不含真人的视频、官方允许的模型产物或已授权的 `asset://` 素材;参考素材上传目录改为跟随 SQLite 数据目录,并补充公开素材的 HEAD 访问。 - Seedance 参考素材失败原因排查:后端会把火山上游错误摘要返回给前端;`/video` 和画布视频生成会按 `图片1/视频1/音频1` 自动编号参考素材,并在实际请求提示词中注入编号说明;参考视频会在请求前校验大小、时长、宽高、宽高比和像素总量。 - 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。 - 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。 diff --git a/handler/ai.go b/handler/ai.go index c569c0a..26d5d7d 100644 --- a/handler/ai.go +++ b/handler/ai.go @@ -263,6 +263,9 @@ func aiUpstreamErrorDetail(body []byte) string { } if err := json.Unmarshal(body, &payload); err == nil { if payload.Error.Message != "" { + if detail := friendlyUpstreamError(payload.Error.Code, payload.Error.Message); detail != "" { + return safeUpstreamText(detail) + } if payload.Error.Code != "" { return safeUpstreamText(payload.Error.Code + " " + payload.Error.Message) } @@ -278,6 +281,14 @@ func aiUpstreamErrorDetail(body []byte) string { return safeUpstreamText(text) } +func friendlyUpstreamError(code string, message string) string { + lowerCode := strings.ToLower(strings.TrimSpace(code)) + if strings.Contains(lowerCode, "inputvideosensitivecontentdetected") || strings.Contains(lowerCode, "privacyinformation") { + return strings.TrimSpace(code + " 参考视频疑似包含真人或隐私信息,火山方舟拒绝使用普通 URL 作为真人视频参考;请改用不含真人的视频、官方允许的模型产物,或已授权的 asset:// 素材。原始错误:" + message) + } + return "" +} + func safeUpstreamText(text string) string { text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ") runes := []rune(text) diff --git a/handler/ai_test.go b/handler/ai_test.go index 2c4d8d4..2eaa897 100644 --- a/handler/ai_test.go +++ b/handler/ai_test.go @@ -12,6 +12,13 @@ func TestAIUpstreamErrorDetail(t *testing.T) { } } +func TestAIUpstreamErrorDetailExplainsSensitiveVideo(t *testing.T) { + got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InputVideoSensitiveContentDetected.PrivacyInformation","message":"The request failed because the input video may contain real person."}}`)) + if !strings.Contains(got, "参考视频疑似包含真人") || !strings.Contains(got, "asset://") { + t.Fatalf("detail = %q", got) + } +} + func TestSafeUpstreamTextTruncates(t *testing.T) { got := safeUpstreamText(strings.Repeat("错", 320)) if len([]rune(got)) != 303 { diff --git a/handler/media_reference.go b/handler/media_reference.go index b528588..94d64b7 100644 --- a/handler/media_reference.go +++ b/handler/media_reference.go @@ -117,7 +117,25 @@ func ReferenceMedia(w http.ResponseWriter, r *http.Request, id string) { } func referenceMediaDir() string { - return filepath.Join("data", "reference-media") + return filepath.Join(referenceDataDir(), "reference-media") +} + +func referenceDataDir() string { + driver := strings.ToLower(strings.TrimSpace(config.Cfg.StorageDriver)) + dsn := strings.TrimSpace(config.Cfg.DatabaseDSN) + if (driver == "" || driver == "sqlite") && dsn != "" && dsn != ":memory:" && !strings.HasPrefix(dsn, "file:") { + pathPart := dsn + if index := strings.Index(dsn, "?"); index >= 0 { + pathPart = dsn[:index] + } + if filepath.IsAbs(pathPart) { + return filepath.Dir(pathPart) + } + } + if _, err := os.Stat("/app/data"); err == nil { + return "/app/data" + } + return "data" } func normalizeReferenceMediaType(contentType string, ext string) (string, string, bool) { diff --git a/handler/media_reference_test.go b/handler/media_reference_test.go index 95cd7fe..826e045 100644 --- a/handler/media_reference_test.go +++ b/handler/media_reference_test.go @@ -1,6 +1,11 @@ package handler -import "testing" +import ( + "path/filepath" + "testing" + + "github.com/basketikun/infinite-canvas/config" +) func TestNormalizeReferenceMediaTypeSupportsAudio(t *testing.T) { tests := []struct { @@ -38,3 +43,14 @@ func TestReferenceMediaTypeMaxBytes(t *testing.T) { t.Fatalf("image max bytes = %d, want %d", got, referenceImageMaxBytes) } } + +func TestReferenceMediaDirUsesAbsoluteSQLiteDataDir(t *testing.T) { + previous := config.Cfg + t.Cleanup(func() { config.Cfg = previous }) + root := t.TempDir() + config.Cfg = config.Config{StorageDriver: "sqlite", DatabaseDSN: filepath.Join(root, "infinite-canvas.db")} + + if got := referenceMediaDir(); got != filepath.Join(root, "reference-media") { + t.Fatalf("referenceMediaDir = %q", got) + } +} diff --git a/router/router.go b/router/router.go index 81525d5..6493903 100644 --- a/router/router.go +++ b/router/router.go @@ -25,6 +25,9 @@ func New() *gin.Engine { api.GET("/media/references/:id", func(c *gin.Context) { handler.ReferenceMedia(c.Writer, c.Request, c.Param("id")) }) + api.HEAD("/media/references/:id", func(c *gin.Context) { + handler.ReferenceMedia(c.Writer, c.Request, c.Param("id")) + }) v1 := api.Group("/v1", middleware.UserAuth) v1.POST("/images/generations", gin.WrapF(handler.AIImagesGenerations)) v1.POST("/images/edits", gin.WrapF(handler.AIImagesEdits))