增加即梦的生图和生视频功能

This commit is contained in:
andy
2026-08-19 17:58:22 +08:00
parent a9a4cdf125
commit 542246e4ce
20 changed files with 1008 additions and 53 deletions

View File

@@ -144,8 +144,9 @@ V1 接入图片生成能力:
后端统一走火山 Visual 异步任务:
- 提交:`CVSync2AsyncSubmitTask`
- 查询:`CVSync2AsyncGetResult`
- 提交:`JimengSeedream46CVToBSubmitTask`
- 查询:`JimengSeedream46CVToBGetResult`
- API Version:`2024-06-06`
未配置火山密钥时,服务会明确报告凭据未配置,不会生成占位图片。
@@ -166,6 +167,7 @@ V1 接入图片生成能力:
- 一个提示词框:图片和视频都在同一创作面板内编辑最终提示词。
- 模式切换:图片模式走即梦图片生成 4.6;视频模式走 Seedance 视频生成。
- 素材统一上传:一个入口上传图片、视频或音频,不再拆分参考图、主体、分镜等栏目。
- Seedance 2.0 单次最多提交 4 个素材(加上文本后 `content` 最多 5 项)。
- `@素材` 引用:上传后自动绑定为 `@图片1`、`@视频1`、`@音频1`,chip 和 @ 候选项都显示缩略图。
- 提示词校验:通过 `/api/prompt/assemble` 检查提示词中引用的素材是否已绑定。
- 任务模块:创作页右侧直接展示任务列表,点击任务可查看完整提示词、输入要素、生成参数、状态和结果。
@@ -219,7 +221,7 @@ cp .env.example .env.local
- `SEEDANCE_MODEL`
- `SEEDANCE_RATIO`:支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`
- `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择
- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`、`4k`;Seedance 2.0 fast 不支持 `1080p`
- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`;Seedance 2.0 fast 不支持 `1080p`
- `ALI_OSS_*`:用于上传素材和生成结果转存
- `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local`
- `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串

View File

@@ -193,8 +193,9 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码
- `duration`:`4` 到 `15` 的整数秒
- `duration=-1`:允许在环境变量或服务端归一化中表示模型自动选择
- `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`
- `resolution`:`480p`、`720p`、`1080p`、`4k`
- `resolution`:`480p`、`720p`、`1080p`
- Seedance 2.0 fast 不支持 `1080p`
- 多模态输入最多 4 个素材(加上文本后 `content` 最多 5 项)
## 任务管理与开放 API

View File

@@ -39,7 +39,11 @@ func firstNonEmpty(values ...string) string {
}
func positiveInt64Env(getenv postgres.Getenv, name string, fallback int64) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(getenv(name)), 10, 64)
return positiveInt64Value(getenv(name), fallback)
}
func positiveInt64Value(raw string, fallback int64) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || value <= 0 {
return fallback
}
@@ -180,9 +184,9 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget
Provider: "seedance",
Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"),
Settings: map[string]any{
"ratio": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"),
"duration": float64(positiveInt64Env(getenv, "SEEDANCE_DEFAULT_DURATION", 5)),
"resolution": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"),
"ratio": firstNonEmpty(getenv("SEEDANCE_RATIO"), getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"),
"duration": float64(positiveInt64Value(firstNonEmpty(getenv("SEEDANCE_DURATION"), getenv("SEEDANCE_DEFAULT_DURATION")), 5)),
"resolution": firstNonEmpty(getenv("SEEDANCE_RESOLUTION"), getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"),
},
},
"bailian": {

View File

@@ -273,6 +273,21 @@ func TestProviderTargetsNeverSelectRemovedProvider(t *testing.T) {
}
}
func TestProviderVideoTargetsUseDocumentedSeedanceDefaults(t *testing.T) {
values := map[string]string{
"SEEDANCE_RATIO": "16:9",
"SEEDANCE_DURATION": "12",
"SEEDANCE_RESOLUTION": "1080p",
}
target := providerVideoTargets(func(name string) string { return values[name] })["seedance"]
if target.Model != "doubao-seedance-2-0-260128" {
t.Fatalf("model=%q", target.Model)
}
if target.Settings["ratio"] != "16:9" || target.Settings["duration"] != float64(12) || target.Settings["resolution"] != "1080p" {
t.Fatalf("settings=%#v", target.Settings)
}
}
func TestCapabilitySummaryMatchesConfiguredDefaultVideoEngine(t *testing.T) {
for _, test := range []struct {
name, engine, wantEngine, wantProvider, wantModel string

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/url"
"strings"
@@ -62,9 +63,16 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
}
}
var result providers.Result
phase := "submit"
expectedStatus := job.Status
if job.ProviderTaskID == "" {
if job.ProviderDispatchStartedAt != nil {
logProviderFailureDiagnostic(providerFailureDiagnostic{
JobID: safeProviderLogToken(job.ID),
Provider: safeProviderLogToken(job.Provider),
Phase: "submit",
ErrorClass: "unknown_outcome",
})
failed := StatusFailed
failure := &JobError{Message: "provider submission outcome is unknown; refusing duplicate submission", Retryable: false}
if p.Store == nil {
@@ -83,12 +91,16 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
}
result, err = adapter.Submit(ctx, request)
} else {
phase = "query"
if modeled, ok := adapter.(providers.ModelQueryAdapter); ok {
result, err = modeled.QueryModel(ctx, job.ProviderTaskID, job.ReqKey)
} else {
result, err = adapter.Query(ctx, job.ProviderTaskID)
}
}
if err != nil {
logProviderFailure(job, phase, err)
}
if err != nil && job.ProviderTaskID == "" && p.Store != nil {
failed := StatusFailed
failure := &JobError{Message: "provider submission outcome is unknown; refusing duplicate submission", Retryable: false}
@@ -140,6 +152,69 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
return job, nil
}
type providerFailureDiagnostic struct {
JobID string
Provider string
Phase string
Status int
ErrorClass string
}
func logProviderFailure(job Job, phase string, err error) {
diagnostic := providerFailureDiagnostic{
JobID: safeProviderLogToken(job.ID),
Provider: safeProviderLogToken(job.Provider),
Phase: safeProviderPhase(phase),
ErrorClass: "provider",
}
if errors.Is(err, context.Canceled) {
diagnostic.ErrorClass = "canceled"
} else if errors.Is(err, context.DeadlineExceeded) {
diagnostic.ErrorClass = "timeout"
} else {
var providerError *providers.ProviderError
if errors.As(err, &providerError) {
diagnostic.Status = providerError.Status
if providerError.Status > 0 {
diagnostic.ErrorClass = "service"
}
}
}
logProviderFailureDiagnostic(diagnostic)
}
func logProviderFailureDiagnostic(diagnostic providerFailureDiagnostic) {
log.Printf(
"zhinian-api generation provider failed jobId=%s provider=%s phase=%s status=%d errorClass=%s",
diagnostic.JobID,
diagnostic.Provider,
diagnostic.Phase,
diagnostic.Status,
diagnostic.ErrorClass,
)
}
func safeProviderPhase(phase string) string {
if phase == "query" {
return "query"
}
return "submit"
}
func safeProviderLogToken(value string) string {
value = strings.TrimSpace(value)
if len(value) == 0 || len(value) > 96 {
return "invalid"
}
for _, character := range value {
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
continue
}
return "invalid"
}
return value
}
func (p ProviderProcessor) refreshAssetURLs(ctx context.Context, job Job, request providers.Request) (providers.Request, error) {
if p.AssetURLs == nil || len(job.InputAssetIDs) == 0 {
return request, nil
@@ -561,6 +636,9 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials []
if engine != "" && engine != "seedance" {
return nil, invalidPreparation("unsupported video engine")
}
if len(materials) > 4 {
return nil, invalidPreparation("seedance supports at most 4 materials")
}
ratio := stringValue(input["ratio"])
if ratio != "" && !oneOf(ratio, "9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive") {
return nil, invalidPreparation("unsupported video ratio")
@@ -576,7 +654,7 @@ func videoSettings(engine string, defaults map[string]any, raw any, materials []
settings["duration"] = duration
}
resolution := stringValue(input["resolution"])
if resolution != "" && !oneOf(resolution, "480p", "720p", "1080p", "4k") {
if resolution != "" && !oneOf(resolution, "480p", "720p", "1080p") {
return nil, invalidPreparation("unsupported video resolution")
}
if resolution != "" {

View File

@@ -1,9 +1,13 @@
package jobs
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
@@ -162,6 +166,16 @@ func TestProviderJobBuilderRejectsInvalidPreparation(t *testing.T) {
{name: "bailian requires frame", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{}}, message: "1 or 2 image materials"},
{name: "bailian rejects video material", capability: "video.generate", body: map[string]any{"engine": "bailian", "prompt": "p", "materials": []any{map[string]any{"url": "https://in.test/a.mp4", "type": "video"}}}, message: "1 or 2 image materials"},
{name: "bad seedance settings", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"duration": 99.0}}, message: "video duration"},
{name: "seedance unsupported 4k output", capability: "video.generate", body: map[string]any{"engine": "seedance", "prompt": "p", "settings": map[string]any{"resolution": "4k"}}, message: "unsupported video resolution"},
{name: "seedance too many materials", capability: "video.generate", body: map[string]any{
"engine": "seedance", "prompt": "p", "materials": []any{
map[string]any{"url": "https://in.test/1.png", "type": "image"},
map[string]any{"url": "https://in.test/2.png", "type": "image"},
map[string]any{"url": "https://in.test/3.png", "type": "image"},
map[string]any{"url": "https://in.test/4.png", "type": "image"},
map[string]any{"url": "https://in.test/5.png", "type": "image"},
},
}, message: "at most 4 materials"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -246,6 +260,79 @@ func TestProviderProcessorNeverResubmitsAfterPersistedDispatchIntent(t *testing.
}
}
func TestProviderProcessorLogsSafeUnknownSubmissionOutcome(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
started := time.Now().UTC()
job := Job{
ID: "job-unknown-outcome", OwnerID: "private-owner", Provider: "volcengine-visual", ReqKey: "private-model",
Capability: "image.generate", Status: StatusRunning, ProviderDispatchStartedAt: &started,
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"private-model","prompt":"private prompt"}`),
}
adapter := &countingProvider{}
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}}
got, err := processor.Advance(context.Background(), job)
if err != nil || got.Status != StatusFailed || adapter.submits != 0 {
t.Fatalf("got=%#v err=%v submits=%d", got, err, adapter.submits)
}
logged := output.String()
for _, expected := range []string{"jobId=job-unknown-outcome", "provider=volcengine-visual", "phase=submit", "status=0", "errorClass=unknown_outcome"} {
if !strings.Contains(logged, expected) {
t.Fatalf("log %q does not contain %q", logged, expected)
}
}
for _, secret := range []string{"private-owner", "private-model", "private prompt"} {
if strings.Contains(logged, secret) {
t.Fatalf("log leaks %q: %s", secret, logged)
}
}
}
func TestProviderProcessorLogsSafeJobCorrelationWhenSubmitFails(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
store := newMemoryJobStore()
job := Job{
ID: "job-safe-log", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob",
Capability: "image.generate", Status: StatusRunning, LockedBy: "worker",
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"private prompt"}`),
}
store.jobs[job.ID] = job
adapter := &countingProvider{err: &providers.ProviderError{Operation: "volcengine request", Status: http.StatusForbidden}}
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store}
got, err := processor.Advance(context.Background(), job)
if err != nil || got.Status != StatusFailed {
t.Fatalf("got=%#v err=%v", got, err)
}
logged := output.String()
for _, expected := range []string{"jobId=job-safe-log", "provider=volcengine-visual", "phase=submit", "status=403", "errorClass=service"} {
if !strings.Contains(logged, expected) {
t.Fatalf("log %q does not contain %q", logged, expected)
}
}
for _, secret := range []string{"private prompt", "jimeng_seedream46_cvtob", "owner", "volcengine request"} {
if strings.Contains(logged, secret) {
t.Fatalf("log leaks %q: %s", secret, logged)
}
}
}
func TestProviderProcessorClearsTransientErrorAfterSuccessfulPoll(t *testing.T) {
store := newMemoryJobStore()
job := Job{ID: "job-recovered", OwnerID: "owner", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusQueued, LockedBy: "worker", ProviderTaskID: "provider-task", Error: &JobError{Message: "temporary timeout", Retryable: true}, RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"hello"}`)}
@@ -290,6 +377,46 @@ func TestProviderProcessorQueriesWithPersistedRequestModel(t *testing.T) {
}
}
func TestProviderProcessorCompletesOfficialVolcengineQueryResponse(t *testing.T) {
var action string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
action = request.URL.Query().Get("Action")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"code":10000,
"data":{"binary_data_base64":null,"image_urls":["https://cdn.test/generated.png"],"status":"done"},
"message":"Success",
"request_id":"request-1",
"status":10000
}`))
}))
defer server.Close()
store := newMemoryJobStore()
job := Job{
ID: "job-jimeng", OwnerID: "owner", Provider: "volcengine-visual", ReqKey: "jimeng_seedream46_cvtob",
Capability: "image.generate", Status: StatusRunning, LockedBy: "worker", ProviderTaskID: "task-1",
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"jimeng_seedream46_cvtob","prompt":"hello"}`),
}
store.jobs[job.ID] = job
adapter := providers.NewVolcengine(providers.Config{
BaseURL: server.URL, AccessKeyID: "ak", SecretAccessKey: "sk", Region: "cn-north-1", Service: "cv", Model: "jimeng_seedream46_cvtob",
}, server.Client(), func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
processor := ProviderProcessor{Providers: ProviderRegistry{"volcengine-visual": adapter}, Store: store}
got, err := processor.Advance(context.Background(), job)
if err != nil {
t.Fatal(err)
}
var persisted providers.HTTPResult
if err := json.Unmarshal(got.ResponsePayload, &persisted); err != nil {
t.Fatal(err)
}
if action != "JimengSeedream46CVToBGetResult" || got.Status != StatusSucceeded || got.ProviderTaskID != "task-1" || len(persisted.OutputURLs) != 1 || persisted.OutputURLs[0] != "https://cdn.test/generated.png" {
t.Fatalf("job=%#v response=%#v", got, persisted)
}
}
func TestProviderJobBuilderRejectsInvalidPublicWebhookURL(t *testing.T) {
for _, value := range []string{"/internal/callback", "javascript:alert(1)", "https://user:pass@example.test/hook"} {
_, err := testProviderBuilder().Build(context.Background(), "api:client", "client", "image.generate", "", map[string]any{"prompt": "hello", "webhookUrl": value})
@@ -320,6 +447,7 @@ type countingProvider struct {
submits int
result providers.Result
request providers.Request
err error
}
type recordingProviderAssetURLResolver struct {
@@ -352,10 +480,10 @@ func (provider *modelQueryProvider) QueryModel(_ context.Context, _ string, mode
func (p *countingProvider) Submit(_ context.Context, request providers.Request) (providers.Result, error) {
p.submits++
p.request = request
return p.result, nil
return p.result, p.err
}
func (p *countingProvider) Query(context.Context, string) (providers.Result, error) {
return p.result, nil
return p.result, p.err
}
func testProviderBuilder() ProviderJobBuilder {

View File

@@ -145,11 +145,7 @@ func NewSeedance(c Config, client HTTPClient) *Seedance {
case MaterialAudio:
materialType, urlKey, role = "audio_url", "audio_url", "reference_audio"
}
item := map[string]any{"type": materialType, urlKey: map[string]any{"url": material.URL}, "role": role}
if material.Label != "" {
item["label"] = material.Label
}
content = append(content, item)
content = append(content, map[string]any{"type": materialType, urlKey: map[string]any{"url": material.URL}, "role": role})
}
p := map[string]any{"model": requestModel(r, c.Model), "content": content, "generate_audio": true, "watermark": false}
for k, v := range r.Settings {

View File

@@ -1,9 +1,11 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"testing"
@@ -128,6 +130,10 @@ func TestVolcenginePayloadsMatchJimengSubmitAndQueryProtocols(t *testing.T) {
if bodies[0]["width"] != float64(1024) || bodies[0]["height"] != float64(768) || bodies[0]["force_single"] != true || bodies[0]["ignored"] != nil {
t.Fatalf("submit body=%#v", bodies[0])
}
imageURLs, ok := bodies[0]["image_urls"].([]any)
if !ok || len(imageURLs) != 1 || imageURLs[0] != "https://cdn.test/ref.png" {
t.Fatalf("submit image_urls=%#v", bodies[0]["image_urls"])
}
queryJSON, ok := bodies[1]["req_json"].(string)
if !ok || queryJSON == "" {
t.Fatalf("query body=%#v", bodies[1])
@@ -142,6 +148,58 @@ func TestVolcenginePayloadsMatchJimengSubmitAndQueryProtocols(t *testing.T) {
}
}
func TestVolcengineSeedream46UsesDedicatedActionsAndVersion(t *testing.T) {
var requests []*http.Request
var bodies []map[string]any
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
requests = append(requests, request.Clone(request.Context()))
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
bodies = append(bodies, body)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"code":10000,"data":{"task_id":"task-1","status":"queued"}}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng_seedream46_cvtob",
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{
Prompt: "draw", Settings: map[string]any{"scale": 50, "width": 2048, "height": 2048, "force_single": true},
}); err != nil {
t.Fatal(err)
}
if _, err := adapter.QueryModel(context.Background(), "task-1", "jimeng_seedream46_cvtob"); err != nil {
t.Fatal(err)
}
wantActions := []string{"JimengSeedream46CVToBSubmitTask", "JimengSeedream46CVToBGetResult"}
if len(requests) != len(wantActions) {
t.Fatalf("requests=%d want=%d", len(requests), len(wantActions))
}
for index, wantAction := range wantActions {
if got := requests[index].URL.Query().Get("Action"); got != wantAction {
t.Fatalf("request[%d] action=%q want=%q", index, got, wantAction)
}
if got := requests[index].URL.Query().Get("Version"); got != "2024-06-06" {
t.Fatalf("request[%d] version=%q want=2024-06-06", index, got)
}
}
if bodies[0]["req_key"] != "jimeng_seedream46_cvtob" || bodies[0]["scale"] != float64(50) {
t.Fatalf("submit body=%#v", bodies[0])
}
if _, exists := bodies[0]["image_urls"]; exists {
t.Fatalf("text-to-image submit contains empty image_urls: %#v", bodies[0])
}
if bodies[1]["req_key"] != "jimeng_seedream46_cvtob" || bodies[1]["task_id"] != "task-1" {
t.Fatalf("query body=%#v", bodies[1])
}
}
func TestVolcengineQueryUsesPersistedTaskModelInsteadOfAdapterFallback(t *testing.T) {
var body map[string]any
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
@@ -162,6 +220,240 @@ func TestVolcengineQueryUsesPersistedTaskModelInsteadOfAdapterFallback(t *testin
}
}
func TestVolcengineDecodesOfficialQueryResponse(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Query().Get("Action") != seedream46QueryAction || request.URL.Query().Get("Version") != seedream46Version {
t.Fatalf("action=%q", request.URL.Query().Get("Action"))
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"code":10000,
"data":{"binary_data_base64":null,"image_urls":["https://cdn.test/generated.png"],"status":"done"},
"message":"Success",
"request_id":"request-1",
"status":10000,
"time_elapsed":"508.312154ms"
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng"}, client, func() time.Time {
return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
})
result, err := adapter.QueryModel(context.Background(), "task-1", "jimeng_seedream46_cvtob")
if err != nil {
t.Fatal(err)
}
if result.TaskID != "task-1" || result.Status != StatusSucceeded || len(result.OutputURLs) != 1 || result.OutputURLs[0] != "https://cdn.test/generated.png" {
t.Fatalf("result=%#v", result)
}
}
func TestVolcengineDecodesGatewayWrappedBusinessResponse(t *testing.T) {
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"ResponseMetadata":{"RequestId":"request-wrapper-1"},
"Result":{
"code":10000,
"data":{"task_id":"task-wrapper-1","status":"done","image_urls":["https://cdn.test/wrapped.png"]},
"message":"Success",
"request_id":"request-business-1"
}
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: seedream46Model}, client, func() time.Time {
return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
})
result, err := adapter.Submit(context.Background(), Request{Prompt: "draw"})
if err != nil {
t.Fatal(err)
}
if result.TaskID != "task-wrapper-1" || result.Status != StatusSucceeded || len(result.OutputURLs) != 1 || result.OutputURLs[0] != "https://cdn.test/wrapped.png" {
t.Fatalf("result=%#v", result)
}
}
func TestVolcengineRejectsOfficialBusinessErrorOnHTTP200(t *testing.T) {
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"code":50413,
"data":null,
"message":"Post Text Risk Not Pass",
"request_id":"request-2",
"status":50413
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "ak", SecretAccessKey: "sk", Model: "jimeng"}, client, func() time.Time {
return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
})
if _, err := adapter.Submit(context.Background(), Request{Prompt: "blocked prompt"}); err == nil {
t.Fatal("expected Volcengine business error")
}
}
func TestVolcengineServiceFailureLogsOnlySafeDiagnosticFields(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(strings.NewReader(`{
"code":50400,
"message":"Authorization failed for private prompt",
"request_id":"request-safe-1",
"data":null
}`)),
Header: http.Header{},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: "jimeng",
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine service error")
}
got := output.String()
for _, expected := range []string{"operation=submit", "status=403", `code="50400"`, `requestId="request-safe-1"`, "errorClass=service", "elapsedMs="} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{"private-access-key", "private-secret-key", "Authorization failed", "private prompt", "Signature=", "Credential="} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestVolcengineServiceFailureLogsNestedGatewayDiagnosticFields(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(`{
"ResponseMetadata": {
"RequestId": "request-nested-1",
"Error": {
"CodeN": 100025,
"Code": "InvalidCredential",
"Message": "Authorization contains private-secret-key and private prompt"
}
}
}`)),
Header: http.Header{"X-Tt-Logid": []string{"request-header-ignored"}},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: seedream46Model,
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine gateway error")
}
got := output.String()
for _, expected := range []string{"operation=submit", "status=400", `code="InvalidCredential"`, `codeN="100025"`, `requestId="request-nested-1"`, "errorClass=service"} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{"private-access-key", "private-secret-key", "Authorization contains", "private prompt", "Signature=", "Credential=", "request-header-ignored"} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestVolcengineServiceFailureFallsBackToSafeResponseHeaderRequestID(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: io.NopCloser(strings.NewReader(`{"ResponseMetadata":{"Error":{"Code":"InternalServiceError","CodeN":"100023"}}}`)),
Header: http.Header{"X-Tt-Logid": []string{"request-header-1"}},
}, nil
})
adapter := NewVolcengine(Config{
BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: seedream46Model,
}, client, func() time.Time { return time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) })
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine gateway error")
}
got := output.String()
for _, expected := range []string{`code="InternalServiceError"`, `codeN="100023"`, `requestId="request-header-1"`} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
}
func TestVolcengineTimeoutLogsSafeTransportClassification(t *testing.T) {
var output bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&output)
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})
adapter := NewVolcengine(Config{BaseURL: "https://visual.test", AccessKeyID: "private-access-key", SecretAccessKey: "private-secret-key", Model: "jimeng"}, client, nil)
if _, err := adapter.Submit(context.Background(), Request{Prompt: "private prompt"}); err == nil {
t.Fatal("expected Volcengine timeout")
}
got := output.String()
for _, expected := range []string{"operation=submit", "status=0", `code=""`, `requestId=""`, "errorClass=timeout", "elapsedMs="} {
if !strings.Contains(got, expected) {
t.Fatalf("log %q does not contain %q", got, expected)
}
}
for _, secret := range []string{"private-access-key", "private-secret-key", "private prompt", "deadline exceeded"} {
if strings.Contains(got, secret) {
t.Fatalf("log leaks %q: %s", secret, got)
}
}
}
func TestProviderErrorsAreGenericAndDoNotLeakSecrets(t *testing.T) {
client := roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader(`{"message":"secret upstream detail"}`)), Header: http.Header{}}, nil
@@ -264,7 +556,7 @@ func TestBailianDecodesCompatibleModeChoiceImages(t *testing.T) {
}
}
func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) {
func TestSeedance20PayloadMatchesOfficialMultimodalContract(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
@@ -281,13 +573,19 @@ func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) {
}
for index, check := range checks {
item := content[index+1].(map[string]any)
if item["type"] != check.materialType || item["role"] != check.role || item["label"] != []string{"图片1", "视频1", "音频1"}[index] {
if item["type"] != check.materialType || item["role"] != check.role {
t.Fatalf("content[%d]=%#v", index+1, item)
}
if _, exists := item["label"]; exists {
t.Fatalf("content[%d] contains unsupported label: %#v", index+1, item)
}
if object := item[check.urlKey].(map[string]any); object["url"] == "" {
t.Fatalf("content[%d]=%#v", index+1, item)
}
}
if body["model"] != "doubao-seedance-2-0-260128" || body["generate_audio"] != true || body["watermark"] != false {
t.Fatalf("body=%#v", body)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-1","status":"queued"}`)), Header: http.Header{}}, nil
})
request := Request{
@@ -298,7 +596,7 @@ func TestSeedancePreservesTypedMultimodalMaterials(t *testing.T) {
{URL: "https://cdn.test/audio.mp3", Type: MaterialAudio, Label: "音频1"},
},
}
if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "seed"}, client).Submit(context.Background(), request); err != nil {
if _, err := NewSeedance(Config{BaseURL: "https://s.test/api/v3", APIKey: "secret", Model: "doubao-seedance-2-0-260128"}, client).Submit(context.Background(), request); err != nil {
t.Fatal(err)
}
}

View File

@@ -8,9 +8,12 @@ import (
"encoding/json"
"errors"
"io"
"log"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
@@ -21,6 +24,20 @@ type Volcengine struct {
now func() time.Time
}
const (
seedream46Model = "jimeng_seedream46_cvtob"
seedream46SubmitAction = "JimengSeedream46CVToBSubmitTask"
seedream46QueryAction = "JimengSeedream46CVToBGetResult"
seedream46Version = "2024-06-06"
visualLegacyVersion = "2022-08-31"
)
type volcengineProtocol struct {
submitAction string
queryAction string
version string
}
func NewVolcengine(c Config, client HTTPClient, now func() time.Time) *Volcengine {
if client == nil {
client = http.DefaultClient
@@ -37,33 +54,57 @@ func NewVolcengine(c Config, client HTTPClient, now func() time.Time) *Volcengin
return &Volcengine{c, client, now}
}
func (v *Volcengine) Submit(ctx context.Context, r Request) (Result, error) {
p := map[string]any{"req_key": requestModel(r, v.config.Model), "prompt": r.Prompt, "image_urls": r.InputURLs}
model := requestModel(r, v.config.Model)
protocol := volcengineProtocolForModel(model)
p := map[string]any{"req_key": model, "prompt": r.Prompt}
if len(r.InputURLs) > 0 {
p["image_urls"] = r.InputURLs
}
for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "force_single"} {
if value, exists := r.Settings[key]; exists && value != nil && value != "" {
p[key] = value
}
}
return v.call(ctx, "CVSync2AsyncSubmitTask", p)
return v.call(ctx, protocol.submitAction, protocol.version, p)
}
func (v *Volcengine) Query(ctx context.Context, id string) (Result, error) {
return v.QueryModel(ctx, id, v.config.Model)
}
func (v *Volcengine) QueryModel(ctx context.Context, id, model string) (Result, error) {
if strings.TrimSpace(id) == "" {
return Result{}, errors.New("provider task id is required")
}
resolvedModel := requestModel(Request{Model: model}, v.config.Model)
protocol := volcengineProtocolForModel(resolvedModel)
queryOptions, _ := json.Marshal(map[string]any{
"return_url": true,
"logo_info": map[string]any{"add_logo": false, "position": 0, "language": 0, "opacity": 1},
})
return v.call(ctx, "CVSync2AsyncGetResult", map[string]any{"req_key": requestModel(Request{Model: model}, v.config.Model), "task_id": id, "req_json": string(queryOptions)})
result, err := v.call(ctx, protocol.queryAction, protocol.version, map[string]any{"req_key": resolvedModel, "task_id": id, "req_json": string(queryOptions)})
if err != nil {
return Result{}, err
}
if result.TaskID == "" {
result.TaskID = id
}
return result, nil
}
func (v *Volcengine) call(ctx context.Context, action string, payload any) (Result, error) {
body, _ := json.Marshal(payload)
func (v *Volcengine) call(ctx context.Context, action, version string, payload any) (Result, error) {
startedAt := time.Now()
operation := volcengineOperation(action)
body, err := json.Marshal(payload)
if err != nil {
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "encode", ElapsedMS: elapsedMilliseconds(startedAt)})
return Result{}, &ProviderError{Operation: "volcengine request"}
}
endpoint, err := url.Parse(v.config.BaseURL)
if err != nil {
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)})
return Result{}, errors.New("invalid provider base URL")
}
q := endpoint.Query()
q.Set("Action", action)
q.Set("Version", "2022-08-31")
q.Set("Version", version)
endpoint.RawQuery = canonicalQuery(q)
date := v.now().UTC()
xdate := date.Format("20060102T150405Z")
@@ -83,26 +124,251 @@ func (v *Volcengine) call(ctx context.Context, action string, payload any) (Resu
key = hmacBytes(key, v.config.Service)
key = hmacBytes(key, "request")
signature := hex.EncodeToString(hmacBytes(key, stringToSign))
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body)))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body)))
if err != nil {
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)})
return Result{}, &ProviderError{Operation: "volcengine request"}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Content-Sha256", hash)
req.Header.Set("X-Date", xdate)
req.Header.Set("Authorization", "HMAC-SHA256 Credential="+v.config.AccessKeyID+"/"+scope+", SignedHeaders="+signed+", Signature="+signature)
resp, err := v.client.Do(req)
if err != nil {
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: classifyVolcengineTransportError(err), ElapsedMS: elapsedMilliseconds(startedAt)})
return Result{}, &ProviderError{Operation: "volcengine request"}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 || !json.Valid(raw) {
limit := v.config.MaxResponseBytes
if limit <= 0 {
limit = 2 << 20
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_read", ElapsedMS: elapsedMilliseconds(startedAt)})
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
}
r := record(raw)
d := object(r["data"])
if int64(len(raw)) > limit {
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_too_large", ElapsedMS: elapsedMilliseconds(startedAt)})
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
}
validJSON := json.Valid(raw)
r := map[string]any{}
if validJSON {
r = record(raw)
}
response := inspectVolcengineResponse(r, resp.Header)
diagnostic := volcengineDiagnostic{
Operation: operation,
Status: resp.StatusCode,
Code: volcengineDiagnosticCode(response.code),
CodeN: volcengineDiagnosticNumericCode(response.codeN),
RequestID: safeVolcengineRequestID(response.requestID),
ElapsedMS: elapsedMilliseconds(startedAt),
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
diagnostic.ErrorClass = "service"
logVolcengineFailure(diagnostic)
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
}
if !validJSON {
diagnostic.ErrorClass = "invalid_json"
logVolcengineFailure(diagnostic)
return Result{}, &ProviderError{Operation: "volcengine request"}
}
if response.code != nil && !volcengineRequestSucceeded(response.code) {
diagnostic.ErrorClass = "service"
logVolcengineFailure(diagnostic)
return Result{}, &ProviderError{Operation: "volcengine request"}
}
d := object(first(response.business["data"], response.business["Data"]))
out := []string{}
collectURLs(d, &out)
return Result{TaskID: stringValue(r["task_id"], d["task_id"]), Status: status(first(r["status"], d["status"])), OutputURLs: out, Raw: raw}, nil
for _, value := range []any{d["image_urls"], d["image_url"], d["url"], d["result_url"], d["output"], d["outputs"]} {
collectURLs(value, &out)
}
return Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw}, nil
}
type volcengineResponse struct {
business map[string]any
code any
codeN any
requestID any
}
func inspectVolcengineResponse(root map[string]any, headers http.Header) volcengineResponse {
business := root
if wrapped := object(first(root["Result"], root["result"])); len(wrapped) > 0 {
business = wrapped
}
metadata := object(first(root["ResponseMetadata"], root["response_metadata"]))
gatewayError := object(first(metadata["Error"], metadata["error"]))
return volcengineResponse{
business: business,
code: first(
business["code"], business["Code"],
root["code"], root["Code"],
gatewayError["Code"], gatewayError["code"],
),
codeN: first(
gatewayError["CodeN"], gatewayError["codeN"], gatewayError["code_n"],
business["code_n"], business["codeN"], business["CodeN"],
root["code_n"], root["codeN"], root["CodeN"],
),
requestID: first(
business["request_id"], business["requestId"], business["RequestId"], business["RequestID"],
root["request_id"], root["requestId"], root["RequestId"], root["RequestID"],
metadata["RequestId"], metadata["RequestID"], metadata["request_id"], metadata["requestId"],
headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"),
),
}
}
func volcengineProtocolForModel(model string) volcengineProtocol {
if strings.TrimSpace(model) == seedream46Model {
return volcengineProtocol{
submitAction: seedream46SubmitAction,
queryAction: seedream46QueryAction,
version: seedream46Version,
}
}
return volcengineProtocol{
submitAction: "CVSync2AsyncSubmitTask",
queryAction: "CVSync2AsyncGetResult",
version: visualLegacyVersion,
}
}
func volcengineRequestSucceeded(value any) bool {
return volcengineDiagnosticCode(value) == "10000"
}
type volcengineDiagnostic struct {
Operation string
Status int
Code string
CodeN string
RequestID string
ErrorClass string
ElapsedMS int64
}
func logVolcengineFailure(diagnostic volcengineDiagnostic) {
log.Printf(
"zhinian-api Volcengine operation failed operation=%s status=%d code=%q codeN=%q requestId=%q errorClass=%s elapsedMs=%d",
diagnostic.Operation,
diagnostic.Status,
diagnostic.Code,
diagnostic.CodeN,
diagnostic.RequestID,
diagnostic.ErrorClass,
diagnostic.ElapsedMS,
)
}
func volcengineOperation(action string) string {
switch action {
case "CVSync2AsyncSubmitTask", seedream46SubmitAction:
return "submit"
case "CVSync2AsyncGetResult", seedream46QueryAction:
return "query"
default:
return "unknown"
}
}
func volcengineDiagnosticCode(value any) string {
code := volcengineDiagnosticValue(value)
if len(code) == 0 || len(code) > 64 {
return ""
}
for _, character := range code {
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
continue
}
return ""
}
return code
}
func volcengineDiagnosticNumericCode(value any) string {
code := volcengineDiagnosticValue(value)
if len(code) == 0 || len(code) > 32 {
return ""
}
for _, character := range code {
if character < '0' || character > '9' {
return ""
}
}
return code
}
func volcengineDiagnosticValue(value any) string {
switch typed := value.(type) {
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
case int:
return strconv.Itoa(typed)
case int32:
return strconv.FormatInt(int64(typed), 10)
case int64:
return strconv.FormatInt(typed, 10)
case json.Number:
return string(typed)
case string:
return strings.TrimSpace(typed)
default:
return ""
}
}
func safeVolcengineRequestID(value any) string {
requestID := stringValue(value)
if len(requestID) == 0 || len(requestID) > 128 {
return ""
}
for _, character := range requestID {
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
continue
}
return ""
}
return requestID
}
func classifyVolcengineTransportError(err error) string {
if errors.Is(err, context.Canceled) {
return "canceled"
}
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
var networkError net.Error
if errors.As(err, &networkError) && networkError.Timeout() {
return "timeout"
}
var dnsError *net.DNSError
if errors.As(err, &dnsError) {
return "dns"
}
var operationError *net.OpError
if errors.As(err, &operationError) && operationError.Op == "dial" {
return "connect"
}
return "transport"
}
func elapsedMilliseconds(startedAt time.Time) int64 {
elapsed := time.Since(startedAt).Milliseconds()
if elapsed < 0 {
return 0
}
return elapsed
}
func sha(b []byte) string { x := sha256.Sum256(b); return hex.EncodeToString(x[:]) }
func hmacBytes(k []byte, s string) []byte {
h := hmac.New(sha256.New, k)

View File

@@ -1,10 +1,10 @@
{
"version": 1,
"providers": {
"volcengine-visual": {"submitAction": "CVSync2AsyncSubmitTask", "queryAction": "CVSync2AsyncGetResult", "auth": "HMAC-SHA256"},
"volcengine-visual": {"model": "jimeng_seedream46_cvtob", "submitAction": "JimengSeedream46CVToBSubmitTask", "queryAction": "JimengSeedream46CVToBGetResult", "version": "2024-06-06", "auth": "HMAC-SHA256"},
"evolink": {"submit": "/v1/images/generations", "query": "/v1/tasks/{id}", "auth": "bearer"},
"bailian": {"imageSubmit": "/api/v1/services/aigc/image-generation/generation", "videoSubmit": "/api/v1/services/aigc/video-generation/video-synthesis", "query": "/api/v1/tasks/{id}", "auth": "bearer"},
"seedance": {"submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer"}
"seedance": {"model": "doubao-seedance-2-0-260128", "submit": "/contents/generations/tasks", "query": "/contents/generations/tasks/{id}", "auth": "bearer", "maxMaterials": 4, "resolutions": ["480p", "720p", "1080p"]}
},
"errors": {"generic": true, "secretSafe": true},
"liveCallsInTests": false

View File

@@ -139,12 +139,12 @@ export function materialContentForProvider(materials: PromptMaterial[], origin:
return normalizeMaterials(materials).map((material) => {
const url = toAbsoluteUrl(material.url, origin);
if (material.type === "video") {
return { type: "video_url", video_url: { url }, role: "reference_video", label: material.label };
return { type: "video_url", video_url: { url }, role: "reference_video" };
}
if (material.type === "audio") {
return { type: "audio_url", audio_url: { url }, role: "reference_audio", label: material.label };
return { type: "audio_url", audio_url: { url }, role: "reference_audio" };
}
return { type: "image_url", image_url: { url }, role: "reference_image", label: material.label };
return { type: "image_url", image_url: { url }, role: "reference_image" };
});
}

View File

@@ -23,7 +23,7 @@ export type SeedanceCreateInput = {
};
export type SeedanceQueryResult = {
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
status: "queued" | "running" | "succeeded" | "failed" | "cancelled" | "expired";
resultUrl?: string;
errorMessage?: string;
usage?: SeedanceUsage;
@@ -49,11 +49,15 @@ export function getSeedanceConfig() {
export async function createSeedanceTask(input: SeedanceCreateInput) {
const config = getSeedanceConfig();
if (!config.apiKey) throw new Error("缺少 SEEDANCE_API_KEY。请在 .env.local 配置火山方舟 API Key。");
const materialContent = materialContentForProvider(input.materials, input.origin);
if (materialContent.length > 4) {
throw new Error("Seedance 2.0 最多支持 4 个素材(加上文本后 content 最多 5 项)。");
}
const payload = {
model: config.model,
content: [
{ type: "text", text: input.prompt },
...materialContentForProvider(input.materials, input.origin)
...materialContent
],
generate_audio: true,
ratio: normalizeVideoRatio(input.settings.ratio, config.ratio),
@@ -123,7 +127,8 @@ export function extractSeedanceUsage(value: unknown): SeedanceUsage | undefined
function normalizeSeedanceStatus(status: unknown): SeedanceQueryResult["status"] {
const value = String(status || "").toLowerCase();
if (["succeeded", "success", "completed"].includes(value)) return "succeeded";
if (["failed", "error", "expired", "timeout"].includes(value)) return "failed";
if (["expired", "timeout"].includes(value)) return "expired";
if (["failed", "error"].includes(value)) return "failed";
if (["cancelled", "canceled"].includes(value)) return "cancelled";
if (["running", "processing", "generating"].includes(value)) return "running";
return "queued";

View File

@@ -11,7 +11,7 @@ export const VIDEO_RATIO_DEFAULT = "9:16";
export const VIDEO_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive"] as const;
export const VIDEO_RESOLUTION_DEFAULT = "720p";
export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p", "4k"] as const;
export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p"] as const;
export const VIDEO_FAST_RESOLUTIONS = ["720p", "480p"] as const;
type VideoDurationOptions = {

View File

@@ -27,7 +27,8 @@ export async function submitVisualTask(
config = getVisualClientConfig()
): Promise<VisualTaskSubmitResponse> {
if (!config) throw new Error("Volcengine Visual credentials are not configured.");
return callVisualApi<VisualTaskSubmitResponse>("CVSync2AsyncSubmitTask", payload, config);
const protocol = visualProtocolForPayload(payload);
return callVisualApi<VisualTaskSubmitResponse>(protocol.submitAction, protocol.version, payload, config);
}
export async function queryVisualTask(
@@ -35,11 +36,38 @@ export async function queryVisualTask(
config = getVisualClientConfig()
): Promise<VisualTaskQueryResponse> {
if (!config) throw new Error("Volcengine Visual credentials are not configured.");
return callVisualApi<VisualTaskQueryResponse>("CVSync2AsyncGetResult", payload, config);
const protocol = visualProtocolForPayload(payload);
return callVisualApi<VisualTaskQueryResponse>(protocol.queryAction, protocol.version, payload, config);
}
type VisualAction =
| "CVSync2AsyncSubmitTask"
| "CVSync2AsyncGetResult"
| "JimengSeedream46CVToBSubmitTask"
| "JimengSeedream46CVToBGetResult";
function visualProtocolForPayload(payload: Record<string, unknown>): {
submitAction: VisualAction;
queryAction: VisualAction;
version: string;
} {
if (String(payload.req_key || "").trim() === "jimeng_seedream46_cvtob") {
return {
submitAction: "JimengSeedream46CVToBSubmitTask",
queryAction: "JimengSeedream46CVToBGetResult",
version: "2024-06-06"
};
}
return {
submitAction: "CVSync2AsyncSubmitTask",
queryAction: "CVSync2AsyncGetResult",
version: "2022-08-31"
};
}
async function callVisualApi<T>(
action: "CVSync2AsyncSubmitTask" | "CVSync2AsyncGetResult",
action: VisualAction,
version: string,
payload: Record<string, unknown>,
config: VisualClientConfig
): Promise<T> {
@@ -49,7 +77,7 @@ async function callVisualApi<T>(
endpoint: config.endpoint,
query: {
Action: action,
Version: "2022-08-31"
Version: version
},
body,
accessKeyId: config.accessKeyId,

View File

@@ -13,6 +13,16 @@ describe("create studio template interaction", () => {
expect(applyTemplate).not.toMatch(/\bsetMode\s*\(/);
expect(applyTemplate).toContain("[generateMode]: template.prompt");
});
it("keeps the effect preview display-only instead of adding it to generation materials", async () => {
const source = await readFile(createStudioUrl, "utf8");
const applyTemplate = functionSource(source, "applyTemplateToConsole", "resetTemplateForm");
const buildBody = functionSource(source, "buildGenerationBody", "submit");
expect(applyTemplate).not.toMatch(/\bsetMaterials\s*\(/);
expect(applyTemplate).not.toContain("previewImageUrl");
expect(buildBody).not.toContain("previewImageUrl");
});
});
function functionSource(source: string, name: string, nextName: string) {

View File

@@ -1,3 +1,24 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("Go provider adapter contract",()=>{it("freezes bounded adapters and safe errors",async()=>{const c=JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json",import.meta.url),"utf8"));expect(Object.keys(c.providers)).toEqual(["volcengine-visual","evolink","bailian","seedance"]);expect(c.errors).toEqual({generic:true,secretSafe:true});expect(c.liveCallsInTests).toBe(false);});});
describe("Go provider adapter contract", () => {
it("freezes bounded adapters and safe errors", async () => {
const contract = JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json", import.meta.url), "utf8"));
expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance"]);
expect(contract.providers["volcengine-visual"]).toMatchObject({
model: "jimeng_seedream46_cvtob",
submitAction: "JimengSeedream46CVToBSubmitTask",
queryAction: "JimengSeedream46CVToBGetResult",
version: "2024-06-06"
});
expect(contract.providers.seedance).toMatchObject({
model: "doubao-seedance-2-0-260128",
submit: "/contents/generations/tasks",
query: "/contents/generations/tasks/{id}",
maxMaterials: 4,
resolutions: ["480p", "720p", "1080p"]
});
expect(contract.errors).toEqual({ generic: true, secretSafe: true });
expect(contract.liveCallsInTests).toBe(false);
});
});

View File

@@ -1,10 +1,80 @@
import { describe, expect, it } from "vitest";
import { extractSeedanceUsage } from "@/lib/seedance/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSeedanceTask, extractSeedanceUsage, querySeedanceTask } from "@/lib/seedance/client";
describe("Seedance usage extraction", () => {
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.SEEDANCE_API_KEY;
delete process.env.SEEDANCE_BASE_URL;
delete process.env.SEEDANCE_MODEL;
});
it("reads completion_tokens from top-level and nested provider responses", () => {
expect(extractSeedanceUsage({ usage: { completion_tokens: 12345 } })).toEqual({ completionTokens: 12345 });
expect(extractSeedanceUsage({ data: { usage: { completionTokens: 67890 } } })).toEqual({ completionTokens: 67890 });
expect(extractSeedanceUsage({ usage: { prompt_tokens: 100 } })).toBeUndefined();
});
it("sends the official Seedance 2.0 multimodal payload without UI labels", async () => {
process.env.SEEDANCE_API_KEY = "test-key";
process.env.SEEDANCE_BASE_URL = "https://ark.test/api/v3";
process.env.SEEDANCE_MODEL = "doubao-seedance-2-0-260128";
let body: Record<string, unknown> | undefined;
vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ id: "task-1" }), { status: 200, headers: { "Content-Type": "application/json" } });
}));
await createSeedanceTask({
prompt: "combine",
settings: { ratio: "16:9", duration: 8, resolution: "1080p" },
materials: [
{ type: "image", url: "/image.png", label: "@图片1" },
{ type: "video", url: "/video.mp4", label: "@视频1" },
{ type: "audio", url: "/audio.mp3", label: "@音频1" }
],
origin: "https://app.test"
});
expect(body).toMatchObject({
model: "doubao-seedance-2-0-260128",
generate_audio: true,
ratio: "16:9",
duration: 8,
resolution: "1080p",
watermark: false
});
const content = body?.content as Array<Record<string, unknown>>;
expect(content).toHaveLength(4);
expect(content.slice(1).every((item) => !("label" in item))).toBe(true);
});
it("keeps the provider expired status distinct from a failed task", async () => {
process.env.SEEDANCE_API_KEY = "test-key";
process.env.SEEDANCE_BASE_URL = "https://ark.test/api/v3";
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ id: "task-1", status: "expired" }), {
status: 200,
headers: { "Content-Type": "application/json" }
})));
await expect(querySeedanceTask("task-1")).resolves.toMatchObject({ status: "expired" });
});
it("rejects more materials than the Seedance 2.0 content limit before calling the provider", async () => {
process.env.SEEDANCE_API_KEY = "test-key";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(createSeedanceTask({
prompt: "combine",
settings: {},
materials: Array.from({ length: 5 }, (_, index) => ({
type: "image" as const,
url: `/image-${index + 1}.png`,
label: `@图片${index + 1}`
})),
origin: "https://app.test"
})).rejects.toThrow("最多支持 4 个素材");
expect(fetchMock).not.toHaveBeenCalled();
});
});

View File

@@ -54,10 +54,10 @@ describe("task management and public API helpers", () => {
delete process.env.ALI_OSS_PUBLIC_BASE_URL;
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const target = String(input);
if (target.includes("CVSync2AsyncSubmitTask")) {
if (target.includes("JimengSeedream46CVToBSubmitTask")) {
return new Response(JSON.stringify({ code: 10000, data: { task_id: "visual-task-1" } }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (target.includes("CVSync2AsyncGetResult")) {
if (target.includes("JimengSeedream46CVToBGetResult")) {
return new Response(JSON.stringify({ code: 10000, data: { status: "done", image_urls: ["https://cdn.test/result.png"] } }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (target === "https://cdn.test/result.png") {

View File

@@ -32,6 +32,7 @@ describe("video settings", () => {
expect(normalizeVideoRatio("21:9")).toBe("21:9");
expect(normalizeVideoRatio("bad-ratio")).toBe("9:16");
expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-260128")).toBe("1080p");
expect(normalizeVideoResolution("4k", "doubao-seedance-2-0-260128")).toBe("720p");
expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-fast-260128")).toBe("720p");
});
});

View File

@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { sha256Hex, signVolcengineRequest } from "@/lib/volcengine/signature";
import { queryVisualTask, submitVisualTask, type VisualClientConfig } from "@/lib/volcengine/visual-client";
describe("Volcengine Visual signing", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("creates canonical request and signed headers", () => {
const signed = signVolcengineRequest({
method: "POST",
@@ -10,7 +15,7 @@ describe("Volcengine Visual signing", () => {
Version: "2022-08-31",
Action: "CVSync2AsyncSubmitTask"
},
body: JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" }),
body: JSON.stringify({ req_key: "legacy-model", prompt: "test" }),
accessKeyId: "ak",
secretAccessKey: "sk",
region: "cn-north-1",
@@ -19,7 +24,34 @@ describe("Volcengine Visual signing", () => {
});
expect(signed.url).toBe("https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31");
expect(signed.headers.Authorization).toContain("HMAC-SHA256 Credential=ak/20260528/cn-north-1/cv/request");
expect(signed.headers["X-Content-Sha256"]).toBe(sha256Hex(JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" })));
expect(signed.headers["X-Content-Sha256"]).toBe(sha256Hex(JSON.stringify({ req_key: "legacy-model", prompt: "test" })));
expect(signed.canonicalRequest).toContain("content-type;host;x-content-sha256;x-date");
});
it("uses the dedicated Seedream 4.6 submit and query protocol", async () => {
const urls: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
return new Response(JSON.stringify({ code: 10000, data: { task_id: "task-1", status: "queued" } }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}));
const config: VisualClientConfig = {
accessKeyId: "ak",
secretAccessKey: "sk",
region: "cn-north-1",
service: "cv",
endpoint: "https://visual.volcengineapi.com"
};
await submitVisualTask({ req_key: "jimeng_seedream46_cvtob", prompt: "draw", scale: 50 }, config);
await queryVisualTask({ req_key: "jimeng_seedream46_cvtob", task_id: "task-1" }, config);
expect(urls).toEqual([
"https://visual.volcengineapi.com/?Action=JimengSeedream46CVToBSubmitTask&Version=2024-06-06",
"https://visual.volcengineapi.com/?Action=JimengSeedream46CVToBGetResult&Version=2024-06-06"
]);
});
});