Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions docs/depth-media-api.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DepthMedia API 调用说明

DepthMedia 是统一的异步媒体处理接口。深度视频、图片去背景和图片高清放大都通过
DepthMedia 是统一的异步媒体处理接口。深度视频、视频去字幕、图片去背景和图片高清放大都通过
`POST /v1/jobs` 提交。客户端会立即获得公开 `task_id`,随后可轮询任务状态,也可以
通过 Webhook 接收最终结果。

Expand Down Expand Up @@ -60,7 +60,8 @@ POST /v1/jobs
| `image-upscale` | 高保真 4 倍 | `upscale` | `fidelity` | `4` | `$0.05` |
| `image-upscale` | 锐化 4 倍 | `upscale` | `sharp` | `4` | `$0.05` |

模型广场只展示 `depth-video`、`background-remove`、`image-upscale` 三个模型。
模型广场只展示 `depth-video`、`background-remove`、`image-upscale`、
`subtitle-remove` 四个模型。
具体处理档位和价格由参数决定,并在模型详情抽屉中展示。图片格式支持上游允许的
`png` 和 `webp`。

Expand All @@ -80,6 +81,35 @@ curl https://api.opwan.ai/v1/jobs \
}'
```

## 视频去字幕

模型:`subtitle-remove`

按源视频实际时长计费,单价为 `$0.02/秒`。系统根据上游返回的帧数和 FPS
计算时长,不采用客户端申报值;不足一秒的部分向上取整。单个视频最长 600 秒,
提交时按 600 秒预扣,任务完成后按实际秒数结算并退回差额。

`subtitle_area` 支持:

- `bottom`:只扫描画面底部字幕区域,默认值,速度更快。
- `full`:扫描完整画面,适用于字幕位置不固定的视频。

```bash
curl https://api.opwan.ai/v1/jobs \
-H "Authorization: Bearer $OPWAN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "subtitle-remove",
"source_url": "https://cdn.example.com/captioned.mp4",
"operation": "remove_subtitles",
"quality": "quality",
"format": "mp4",
"subtitle_area": "bottom",
"webhook_url": "https://client.example.com/webhooks/depth-media",
"webhook_secret": "replace-with-your-secret"
}'
```

## 提交响应

提交成功返回 HTTP `202`:
Expand Down
21 changes: 15 additions & 6 deletions middleware/depth_media_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type depthMediaRequest struct {
Quality string `json:"quality,omitempty"`
Scale int `json:"scale,omitempty"`
Format string `json:"format,omitempty"`
SubtitleArea string `json:"subtitle_area,omitempty"`
WebhookURL string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"webhook_secret,omitempty"`
}
Expand Down Expand Up @@ -57,7 +58,8 @@ func DepthMediaRequestConvert() gin.HandlerFunc {
modelName = taskdepthmedia.ModelDepthVideo
} else if modelName == "" ||
modelName == taskdepthmedia.PublicModelBackgroundRemove ||
modelName == taskdepthmedia.PublicModelImageUpscale {
modelName == taskdepthmedia.PublicModelImageUpscale ||
modelName == taskdepthmedia.PublicModelSubtitleRemove {
resolved, err := taskdepthmedia.ResolveModel(request.Operation, request.Quality, request.Scale)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
Expand All @@ -76,15 +78,22 @@ func DepthMediaRequestConvert() gin.HandlerFunc {
c.Abort()
return
}
if modelName == taskdepthmedia.PublicModelSubtitleRemove &&
!strings.EqualFold(strings.TrimSpace(request.Operation), "remove_subtitles") {
c.JSON(http.StatusBadRequest, gin.H{"error": "subtitle-remove requires operation remove_subtitles"})
c.Abort()
return
}
modelName = resolved
}

metadata := map[string]any{
"source_url": request.SourceURL,
"operation": request.Operation,
"quality": request.Quality,
"scale": request.Scale,
"format": request.Format,
"source_url": request.SourceURL,
"operation": request.Operation,
"quality": request.Quality,
"scale": request.Scale,
"format": request.Format,
"subtitle_area": request.SubtitleArea,
}
unified := relaycommon.TaskSubmitReq{
Prompt: "process media",
Expand Down
5 changes: 5 additions & 0 deletions middleware/depth_media_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ func TestDepthMediaRequestConvertAcceptsPublicCatalogAliases(t *testing.T) {
body: `{"model":"image-upscale","source_url":"https://cdn.example.com/input.png","operation":"upscale","quality":"sharp","scale":4}`,
wantModel: taskdepthmedia.ModelUpscaleSharp4X,
},
{
name: "subtitle removal",
body: `{"model":"subtitle-remove","source_url":"https://cdn.example.com/input.mp4","operation":"remove_subtitles","quality":"quality","format":"mp4","subtitle_area":"bottom"}`,
wantModel: taskdepthmedia.ModelSubtitleRemove,
},
}

for _, tt := range tests {
Expand Down
79 changes: 66 additions & 13 deletions relay/channel/task/depthmedia/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ const (
ActionDepth = "depth"
ActionMedia = "media"

maxDepthVideoBillingSeconds = 10 * 60
maxVideoBillingSeconds = 10 * 60

ModelDepthVideo = "depth-anything-v2-small-video"
ModelSubtitleRemove = "subtitle-remove"
ModelBackgroundFast = "background-remove-fast"
ModelBackgroundQuality = "background-remove-quality"
ModelBackgroundMatting = "background-remove-matting"
Expand All @@ -37,12 +38,14 @@ const (
ModelUpscaleSharp4X = "image-upscale-sharp-4x"

PublicModelDepthVideo = "depth-video"
PublicModelSubtitleRemove = ModelSubtitleRemove
PublicModelBackgroundRemove = "background-remove"
PublicModelImageUpscale = "image-upscale"
)

var supportedModels = []string{
ModelDepthVideo,
ModelSubtitleRemove,
ModelBackgroundFast,
ModelBackgroundQuality,
ModelBackgroundMatting,
Expand All @@ -53,11 +56,12 @@ var supportedModels = []string{
}

type requestPayload struct {
SourceURL string `json:"source_url"`
Operation string `json:"operation,omitempty"`
Quality string `json:"quality,omitempty"`
Scale int `json:"scale,omitempty"`
Format string `json:"format,omitempty"`
SourceURL string `json:"source_url"`
Operation string `json:"operation,omitempty"`
Quality string `json:"quality,omitempty"`
Scale int `json:"scale,omitempty"`
Format string `json:"format,omitempty"`
SubtitleArea string `json:"subtitle_area,omitempty"`
}

type responsePayload struct {
Expand Down Expand Up @@ -105,6 +109,11 @@ func ResolveModel(operation, quality string, scale int) (string, error) {
return ModelUpscaleSharp4X, nil
}
}
case "remove_subtitles":
normalizedQuality := strings.ToLower(strings.TrimSpace(quality))
if scale == 0 && (normalizedQuality == "" || normalizedQuality == "quality") {
return ModelSubtitleRemove, nil
}
}
return "", fmt.Errorf("unsupported media profile: operation=%q quality=%q scale=%d", operation, quality, scale)
}
Expand Down Expand Up @@ -139,13 +148,54 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
if err := request.UnmarshalMetadata(&metadata); err != nil {
return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
}
metadata.Operation = strings.ToLower(strings.TrimSpace(metadata.Operation))
metadata.Quality = strings.ToLower(strings.TrimSpace(metadata.Quality))
metadata.Format = strings.ToLower(strings.TrimSpace(metadata.Format))
metadata.SubtitleArea = strings.ToLower(strings.TrimSpace(metadata.SubtitleArea))
if metadata.Operation == "remove_subtitles" {
if metadata.Quality == "" {
metadata.Quality = "quality"
}
if metadata.Format == "" {
metadata.Format = "mp4"
}
if metadata.SubtitleArea == "" {
metadata.SubtitleArea = "bottom"
}
}
resolved, err := ResolveModel(metadata.Operation, metadata.Quality, metadata.Scale)
if err != nil || resolved != request.Model {
if err == nil {
err = fmt.Errorf("model does not match media profile")
}
return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
}
if resolved == ModelSubtitleRemove {
format := strings.ToLower(strings.TrimSpace(metadata.Format))
if format != "" && format != "mp4" {
return service.TaskErrorWrapperLocal(
fmt.Errorf("subtitle removal format must be mp4"),
"invalid_request",
http.StatusBadRequest,
)
}
subtitleArea := strings.ToLower(strings.TrimSpace(metadata.SubtitleArea))
if subtitleArea != "" && subtitleArea != "bottom" && subtitleArea != "full" {
return service.TaskErrorWrapperLocal(
fmt.Errorf("subtitle_area must be bottom or full"),
"invalid_request",
http.StatusBadRequest,
)
}
}
request.Metadata = map[string]interface{}{
"source_url": metadata.SourceURL,
"operation": metadata.Operation,
"quality": metadata.Quality,
"scale": metadata.Scale,
"format": metadata.Format,
"subtitle_area": metadata.SubtitleArea,
}
}
info.Action = action
c.Set("action", action)
Expand Down Expand Up @@ -200,20 +250,23 @@ func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, bod
}

func (a *TaskAdaptor) EstimateBilling(_ *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
if info.OriginModelName != ModelDepthVideo {
if info.OriginModelName != ModelDepthVideo && info.OriginModelName != ModelSubtitleRemove {
return nil
}
return map[string]float64{"seconds": maxDepthVideoBillingSeconds}
return map[string]float64{"seconds": maxVideoBillingSeconds}
}

func (a *TaskAdaptor) AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int {
if task == nil || taskResult == nil || taskResult.Status != model.TaskStatusSuccess ||
task.Action != ActionDepth {
if task == nil || taskResult == nil || taskResult.Status != model.TaskStatusSuccess {
return 0
}
billing := task.PrivateData.BillingContext
if billing == nil || billing.OriginModelName != ModelDepthVideo ||
billing.ModelPrice <= 0 || billing.GroupRatio <= 0 {
if billing == nil || billing.ModelPrice <= 0 || billing.GroupRatio <= 0 {
return 0
}
isDepthVideo := task.Action == ActionDepth && billing.OriginModelName == ModelDepthVideo
isSubtitleRemoval := task.Action == ActionMedia && billing.OriginModelName == ModelSubtitleRemove
if !isDepthVideo && !isSubtitleRemoval {
return 0
}
var response responsePayload
Expand All @@ -222,7 +275,7 @@ func (a *TaskAdaptor) AdjustBillingOnComplete(task *model.Task, taskResult *rela
return 0
}
seconds := math.Ceil(float64(response.Frames) / response.FPS)
seconds = min(seconds, maxDepthVideoBillingSeconds)
seconds = min(seconds, maxVideoBillingSeconds)
quota, clamp := common.QuotaFromFloatChecked(
billing.ModelPrice * common.QuotaPerUnit * billing.GroupRatio * seconds,
)
Expand Down
77 changes: 77 additions & 0 deletions relay/channel/task/depthmedia/adaptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func TestResolveModel(t *testing.T) {
{name: "upscale fast 4x", operation: "upscale", quality: "fast", scale: 4, want: ModelUpscaleFast4X},
{name: "upscale fidelity", operation: "upscale", quality: "fidelity", scale: 4, want: ModelUpscaleFidelity4X},
{name: "upscale sharp", operation: "upscale", quality: "sharp", scale: 4, want: ModelUpscaleSharp4X},
{name: "subtitle removal", operation: "remove_subtitles", quality: "quality", want: ModelSubtitleRemove},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -55,6 +56,8 @@ func TestResolveModel(t *testing.T) {

_, err := ResolveModel("upscale", "fidelity", 2)
require.Error(t, err)
_, err = ResolveModel("remove_subtitles", "quality", 2)
require.Error(t, err)
}

func TestTaskAdaptorBuildsMediaRequestWithoutGatewayWebhookFields(t *testing.T) {
Expand Down Expand Up @@ -122,6 +125,43 @@ func TestTaskAdaptorBuildsDepthRequestWithUnifiedOperation(t *testing.T) {
assert.Equal(t, "depth", payload["operation"])
}

func TestTaskAdaptorBuildsSubtitleRemovalRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(
http.MethodPost,
"/v1/video/generations",
strings.NewReader(`{
"model":"subtitle-remove",
"image":"https://cdn.example.com/captioned.mp4",
"metadata":{
"operation":" Remove_Subtitles ",
"quality":" Quality ",
"format":"MP4",
"subtitle_area":"Bottom"
}
}`),
)
c.Request.Header.Set("Content-Type", "application/json")
info := newTestRelayInfo("https://modal.example.com", "upstream-secret", "")
adaptor := &TaskAdaptor{}
adaptor.Init(info)
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))

body, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
data, err := io.ReadAll(body)
require.NoError(t, err)

var payload map[string]any
require.NoError(t, common.Unmarshal(data, &payload))
assert.Equal(t, "https://cdn.example.com/captioned.mp4", payload["source_url"])
assert.Equal(t, "remove_subtitles", payload["operation"])
assert.Equal(t, "quality", payload["quality"])
assert.Equal(t, "mp4", payload["format"])
assert.Equal(t, "bottom", payload["subtitle_area"])
}

func TestTaskAdaptorValidatesDepthAndMediaRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
Expand All @@ -140,6 +180,16 @@ func TestTaskAdaptorValidatesDepthAndMediaRequests(t *testing.T) {
body: `{"model":"background-remove-fast","image":"https://cdn.example.com/input.png","metadata":{"operation":"remove_background","quality":"fast"}}`,
wantAction: ActionMedia,
},
{
name: "subtitle removal",
body: `{"model":"subtitle-remove","image":"https://cdn.example.com/input.mp4","metadata":{"operation":"remove_subtitles","quality":"quality","format":"mp4","subtitle_area":"full"}}`,
wantAction: ActionMedia,
},
{
name: "subtitle removal rejects scale",
body: `{"model":"subtitle-remove","image":"https://cdn.example.com/input.mp4","metadata":{"operation":"remove_subtitles","quality":"quality","format":"mp4","subtitle_area":"bottom","scale":2}}`,
wantError: true,
},
{
name: "missing source",
body: `{"model":"depth-anything-v2-small-video"}`,
Expand Down Expand Up @@ -302,6 +352,10 @@ func TestTaskAdaptorEstimatesMaximumDepthVideoDuration(t *testing.T) {
imageInfo := newTestRelayInfo("https://modal.example.com", "key", ActionMedia)
imageInfo.OriginModelName = ModelUpscaleFast2X
assert.Nil(t, adaptor.EstimateBilling(c, imageInfo))

subtitleInfo := newTestRelayInfo("https://modal.example.com", "key", ActionMedia)
subtitleInfo.OriginModelName = ModelSubtitleRemove
assert.Equal(t, map[string]float64{"seconds": 600}, adaptor.EstimateBilling(c, subtitleInfo))
}

func TestTaskAdaptorReconcilesDepthVideoToActualDuration(t *testing.T) {
Expand Down Expand Up @@ -368,3 +422,26 @@ func TestTaskAdaptorCapsReportedDurationAtMaximum(t *testing.T) {

assert.Equal(t, 600000, quota)
}

func TestTaskAdaptorReconcilesSubtitleRemovalToActualDuration(t *testing.T) {
adaptor := &TaskAdaptor{}
task := &model.Task{
Action: ActionMedia,
Data: []byte(
`{"id":"job_1","status":"completed","progress":100,"fps":24,"frames":73}`,
),
PrivateData: model.TaskPrivateData{
BillingContext: &model.TaskBillingContext{
ModelPrice: 0.02,
GroupRatio: 1,
OriginModelName: ModelSubtitleRemove,
},
},
}

quota := adaptor.AdjustBillingOnComplete(task, &relaycommon.TaskInfo{
Status: model.TaskStatusSuccess,
})

assert.Equal(t, 40000, quota)
}
1 change: 1 addition & 0 deletions setting/ratio_setting/model_ratio.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ var defaultModelPrice = map[string]float64{
"veo-3.0-fast-generate-001": 0.15,
"veo-3.1-generate-preview": 0.4,
"veo-3.1-fast-generate-preview": 0.15,
"subtitle-remove": 0.02,
}

var defaultAudioRatio = map[string]float64{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,9 @@ function profileParameterForDisplay(
}

const MEDIA_PARAMETER_DESCRIPTION_KEYS: Record<string, string> = {
format: 'Output media format',
quality: 'Media processing quality profile',
subtitle_area: 'Area to scan for hard-coded subtitles',
webhook_url: 'URL receiving asynchronous task completion notifications',
webhook_secret: 'Secret used to sign asynchronous task webhook deliveries',
}
Expand Down
Loading