feat(sora): 新增 Sora 平台支持并修复高危安全和性能问题

新增功能:
- 新增 Sora 账号管理和 OAuth 认证
- 新增 Sora 视频/图片生成 API 网关
- 新增 Sora 任务调度和缓存机制
- 新增 Sora 使用统计和计费支持
- 前端增加 Sora 平台配置界面

安全修复(代码审核):
- [SEC-001] 限制媒体下载响应体大小(图片 20MB、视频 200MB),防止 DoS 攻击
- [SEC-002] 限制 SDK API 响应大小(1MB),防止内存耗尽
- [SEC-003] 修复 SSRF 风险,添加 URL 验证并强制使用代理配置

BUG 修复(代码审核):
- [BUG-001] 修复 for 循环内 defer 累积导致的资源泄漏
- [BUG-002] 修复图片并发槽位获取失败时已持有锁未释放的永久泄漏

性能优化(代码审核):
- [PERF-001] 添加 Sentinel Token 缓存(3 分钟有效期),减少 PoW 计算开销

技术细节:
- 使用 io.LimitReader 限制所有外部输入的大小
- 添加 urlvalidator 验证防止 SSRF 攻击
- 使用 sync.Map 实现线程安全的包级缓存
- 优化并发槽位管理,添加 releaseAll 模式防止泄漏

影响范围:
- 后端:新增 Sora 相关数据模型、服务、网关和管理接口
- 前端:新增 Sora 平台配置、账号管理和监控界面
- 配置:新增 Sora 相关配置项和环境变量

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-29 16:18:38 +08:00
parent bece1b5201
commit 13262a5698
97 changed files with 29541 additions and 68 deletions

View File

@@ -0,0 +1,148 @@
package sora
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
)
// UploadCharacterVideo uploads a character video and returns cameo ID.
func (c *Client) UploadCharacterVideo(ctx context.Context, opts RequestOptions, data []byte) (string, error) {
if len(data) == 0 {
return "", errors.New("video data empty")
}
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
if err := writeMultipartFile(writer, "file", "video.mp4", "video/mp4", data); err != nil {
return "", err
}
if err := writer.WriteField("timestamps", "0,3"); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/characters/upload", opts, &buf, writer.FormDataContentType(), false)
if err != nil {
return "", err
}
return stringFromJSON(resp, "id"), nil
}
// GetCameoStatus returns cameo processing status.
func (c *Client) GetCameoStatus(ctx context.Context, opts RequestOptions, cameoID string) (map[string]any, error) {
if cameoID == "" {
return nil, errors.New("cameo id empty")
}
return c.doRequest(ctx, "GET", "/project_y/cameos/in_progress/"+cameoID, opts, nil, "", false)
}
// DownloadCharacterImage downloads character avatar image data.
func (c *Client) DownloadCharacterImage(ctx context.Context, opts RequestOptions, imageURL string) ([]byte, error) {
if c.upstream == nil {
return nil, errors.New("upstream is nil")
}
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", defaultDesktopUA)
resp, err := c.upstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, opts.AccountConcurrency, c.enableTLSFingerprint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("download image failed: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// UploadCharacterImage uploads character avatar and returns asset pointer.
func (c *Client) UploadCharacterImage(ctx context.Context, opts RequestOptions, data []byte) (string, error) {
if len(data) == 0 {
return "", errors.New("image data empty")
}
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
if err := writeMultipartFile(writer, "file", "profile.webp", "image/webp", data); err != nil {
return "", err
}
if err := writer.WriteField("use_case", "profile"); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/project_y/file/upload", opts, &buf, writer.FormDataContentType(), false)
if err != nil {
return "", err
}
return stringFromJSON(resp, "asset_pointer"), nil
}
// FinalizeCharacter finalizes character creation and returns character ID.
func (c *Client) FinalizeCharacter(ctx context.Context, opts RequestOptions, cameoID, username, displayName, assetPointer string) (string, error) {
payload := map[string]any{
"cameo_id": cameoID,
"username": username,
"display_name": displayName,
"profile_asset_pointer": assetPointer,
"instruction_set": nil,
"safety_instruction_set": nil,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/characters/finalize", opts, bytes.NewReader(body), "application/json", false)
if err != nil {
return "", err
}
if character, ok := resp["character"].(map[string]any); ok {
if id, ok := character["character_id"].(string); ok {
return id, nil
}
}
return "", nil
}
// SetCharacterPublic marks character as public.
func (c *Client) SetCharacterPublic(ctx context.Context, opts RequestOptions, cameoID string) error {
payload := map[string]any{"visibility": "public"}
body, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = c.doRequest(ctx, "POST", "/project_y/cameos/by_id/"+cameoID+"/update_v2", opts, bytes.NewReader(body), "application/json", false)
return err
}
// DeleteCharacter deletes a character by ID.
func (c *Client) DeleteCharacter(ctx context.Context, opts RequestOptions, characterID string) error {
if characterID == "" {
return nil
}
_, err := c.doRequest(ctx, "DELETE", "/project_y/characters/"+characterID, opts, nil, "", false)
return err
}
func writeMultipartFile(writer *multipart.Writer, field, filename, contentType string, data []byte) error {
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, field, filename))
if contentType != "" {
header.Set("Content-Type", contentType)
}
part, err := writer.CreatePart(header)
if err != nil {
return err
}
_, err = part.Write(data)
return err
}

View File

@@ -0,0 +1,612 @@
package sora
import (
"bytes"
"context"
"crypto/sha3"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
const (
chatGPTBaseURL = "https://chatgpt.com"
sentinelFlow = "sora_2_create_task"
maxAPIResponseSize = 1 * 1024 * 1024 // 1MB
)
var (
defaultMobileUA = "Sora/1.2026.007 (Android 15; Pixel 8 Pro; build 2600700)"
defaultDesktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
sentinelCache sync.Map // 包级缓存,存储 Sentinel Tokenkey 为 accountID
)
// sentinelCacheEntry 是 Sentinel Token 缓存条目
type sentinelCacheEntry struct {
token string
expiresAt time.Time
}
// UpstreamClient defines the HTTP client interface for Sora requests.
type UpstreamClient interface {
Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error)
}
// Client is a minimal Sora API client.
type Client struct {
baseURL string
timeout time.Duration
upstream UpstreamClient
enableTLSFingerprint bool
}
// RequestOptions configures per-request context.
type RequestOptions struct {
AccountID int64
AccountConcurrency int
ProxyURL string
AccessToken string
}
// getCachedSentinel 从缓存中获取 Sentinel Token
func getCachedSentinel(accountID int64) (string, bool) {
v, ok := sentinelCache.Load(accountID)
if !ok {
return "", false
}
entry := v.(*sentinelCacheEntry)
if time.Now().After(entry.expiresAt) {
sentinelCache.Delete(accountID)
return "", false
}
return entry.token, true
}
// cacheSentinel 缓存 Sentinel Token
func cacheSentinel(accountID int64, token string) {
sentinelCache.Store(accountID, &sentinelCacheEntry{
token: token,
expiresAt: time.Now().Add(3 * time.Minute), // 3分钟有效期
})
}
// NewClient creates a Sora client.
func NewClient(baseURL string, timeout time.Duration, upstream UpstreamClient, enableTLSFingerprint bool) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
timeout: timeout,
upstream: upstream,
enableTLSFingerprint: enableTLSFingerprint,
}
}
// UploadImage uploads an image and returns media ID.
func (c *Client) UploadImage(ctx context.Context, opts RequestOptions, data []byte, filename string) (string, error) {
if filename == "" {
filename = "image.png"
}
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
if err := writer.WriteField("file_name", filename); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/uploads", opts, &buf, writer.FormDataContentType(), false)
if err != nil {
return "", err
}
return stringFromJSON(resp, "id"), nil
}
// GenerateImage creates an image generation task.
func (c *Client) GenerateImage(ctx context.Context, opts RequestOptions, prompt string, width, height int, mediaID string) (string, error) {
operation := "simple_compose"
var inpaint []map[string]any
if mediaID != "" {
operation = "remix"
inpaint = []map[string]any{
{
"type": "image",
"frame_index": 0,
"upload_media_id": mediaID,
},
}
}
payload := map[string]any{
"type": "image_gen",
"operation": operation,
"prompt": prompt,
"width": width,
"height": height,
"n_variants": 1,
"n_frames": 1,
"inpaint_items": inpaint,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/video_gen", opts, bytes.NewReader(body), "application/json", true)
if err != nil {
return "", err
}
return stringFromJSON(resp, "id"), nil
}
// GenerateVideo creates a video generation task.
func (c *Client) GenerateVideo(ctx context.Context, opts RequestOptions, prompt, orientation string, nFrames int, mediaID, styleID, model, size string) (string, error) {
var inpaint []map[string]any
if mediaID != "" {
inpaint = []map[string]any{{"kind": "upload", "upload_id": mediaID}}
}
payload := map[string]any{
"kind": "video",
"prompt": prompt,
"orientation": orientation,
"size": size,
"n_frames": nFrames,
"model": model,
"inpaint_items": inpaint,
"style_id": styleID,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/nf/create", opts, bytes.NewReader(body), "application/json", true)
if err != nil {
return "", err
}
return stringFromJSON(resp, "id"), nil
}
// GenerateStoryboard creates a storyboard video task.
func (c *Client) GenerateStoryboard(ctx context.Context, opts RequestOptions, prompt, orientation string, nFrames int, mediaID, styleID string) (string, error) {
var inpaint []map[string]any
if mediaID != "" {
inpaint = []map[string]any{{"kind": "upload", "upload_id": mediaID}}
}
payload := map[string]any{
"kind": "video",
"prompt": prompt,
"title": "Draft your video",
"orientation": orientation,
"size": "small",
"n_frames": nFrames,
"storyboard_id": nil,
"inpaint_items": inpaint,
"remix_target_id": nil,
"model": "sy_8",
"metadata": nil,
"style_id": styleID,
"cameo_ids": nil,
"cameo_replacements": nil,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/nf/create/storyboard", opts, bytes.NewReader(body), "application/json", true)
if err != nil {
return "", err
}
return stringFromJSON(resp, "id"), nil
}
// RemixVideo creates a remix task.
func (c *Client) RemixVideo(ctx context.Context, opts RequestOptions, remixTargetID, prompt, orientation string, nFrames int, styleID string) (string, error) {
payload := map[string]any{
"kind": "video",
"prompt": prompt,
"inpaint_items": []map[string]any{},
"remix_target_id": remixTargetID,
"cameo_ids": []string{},
"cameo_replacements": map[string]any{},
"model": "sy_8",
"orientation": orientation,
"n_frames": nFrames,
"style_id": styleID,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/nf/create", opts, bytes.NewReader(body), "application/json", true)
if err != nil {
return "", err
}
return stringFromJSON(resp, "id"), nil
}
// GetImageTasks returns recent image tasks.
func (c *Client) GetImageTasks(ctx context.Context, opts RequestOptions) (map[string]any, error) {
return c.doRequest(ctx, "GET", "/v2/recent_tasks?limit=20", opts, nil, "", false)
}
// GetPendingTasks returns pending video tasks.
func (c *Client) GetPendingTasks(ctx context.Context, opts RequestOptions) ([]map[string]any, error) {
resp, err := c.doRequestAny(ctx, "GET", "/nf/pending/v2", opts, nil, "", false)
if err != nil {
return nil, err
}
switch v := resp.(type) {
case []any:
return convertList(v), nil
case map[string]any:
if list, ok := v["items"].([]any); ok {
return convertList(list), nil
}
if arr, ok := v["data"].([]any); ok {
return convertList(arr), nil
}
return convertListFromAny(v), nil
default:
return nil, nil
}
}
// GetVideoDrafts returns recent video drafts.
func (c *Client) GetVideoDrafts(ctx context.Context, opts RequestOptions) (map[string]any, error) {
return c.doRequest(ctx, "GET", "/project_y/profile/drafts?limit=15", opts, nil, "", false)
}
// EnhancePrompt calls prompt enhancement API.
func (c *Client) EnhancePrompt(ctx context.Context, opts RequestOptions, prompt, expansionLevel string, durationS int) (string, error) {
payload := map[string]any{
"prompt": prompt,
"expansion_level": expansionLevel,
"duration_s": durationS,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/editor/enhance_prompt", opts, bytes.NewReader(body), "application/json", false)
if err != nil {
return "", err
}
return stringFromJSON(resp, "enhanced_prompt"), nil
}
// PostVideoForWatermarkFree publishes a video for watermark-free parsing.
func (c *Client) PostVideoForWatermarkFree(ctx context.Context, opts RequestOptions, generationID string) (string, error) {
payload := map[string]any{
"attachments_to_create": []map[string]any{{
"generation_id": generationID,
"kind": "sora",
}},
"post_text": "",
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
resp, err := c.doRequest(ctx, "POST", "/project_y/post", opts, bytes.NewReader(body), "application/json", true)
if err != nil {
return "", err
}
post, _ := resp["post"].(map[string]any)
if post == nil {
return "", nil
}
return stringFromJSON(post, "id"), nil
}
// DeletePost deletes a Sora post.
func (c *Client) DeletePost(ctx context.Context, opts RequestOptions, postID string) error {
if postID == "" {
return nil
}
_, err := c.doRequest(ctx, "DELETE", "/project_y/post/"+postID, opts, nil, "", false)
return err
}
func (c *Client) doRequest(ctx context.Context, method, endpoint string, opts RequestOptions, body io.Reader, contentType string, addSentinel bool) (map[string]any, error) {
resp, err := c.doRequestAny(ctx, method, endpoint, opts, body, contentType, addSentinel)
if err != nil {
return nil, err
}
parsed, ok := resp.(map[string]any)
if !ok {
return nil, errors.New("unexpected response format")
}
return parsed, nil
}
func (c *Client) doRequestAny(ctx context.Context, method, endpoint string, opts RequestOptions, body io.Reader, contentType string, addSentinel bool) (any, error) {
if c.upstream == nil {
return nil, errors.New("upstream is nil")
}
url := c.baseURL + endpoint
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if opts.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+opts.AccessToken)
}
req.Header.Set("User-Agent", defaultMobileUA)
if addSentinel {
sentinel, err := c.generateSentinelToken(ctx, opts)
if err != nil {
return nil, err
}
req.Header.Set("openai-sentinel-token", sentinel)
}
resp, err := c.upstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, opts.AccountConcurrency, c.enableTLSFingerprint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 使用 LimitReader 限制最大响应大小,防止 DoS 攻击
limitedReader := io.LimitReader(resp.Body, maxAPIResponseSize+1)
data, err := io.ReadAll(limitedReader)
if err != nil {
return nil, err
}
// 检查是否超过大小限制
if int64(len(data)) > maxAPIResponseSize {
return nil, fmt.Errorf("API 响应过大 (最大 %d 字节)", maxAPIResponseSize)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("sora api error: %d %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
if len(data) == 0 {
return map[string]any{}, nil
}
var parsed any
if err := json.Unmarshal(data, &parsed); err != nil {
return nil, err
}
return parsed, nil
}
func (c *Client) generateSentinelToken(ctx context.Context, opts RequestOptions) (string, error) {
// 尝试从缓存获取
if token, ok := getCachedSentinel(opts.AccountID); ok {
return token, nil
}
reqID := uuid.New().String()
powToken, err := generatePowToken(defaultDesktopUA)
if err != nil {
return "", err
}
payload := map[string]any{"p": powToken, "flow": sentinelFlow, "id": reqID}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
url := chatGPTBaseURL + "/backend-api/sentinel/req"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://sora.chatgpt.com")
req.Header.Set("Referer", "https://sora.chatgpt.com/")
req.Header.Set("User-Agent", defaultDesktopUA)
if opts.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+opts.AccessToken)
}
resp, err := c.upstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, opts.AccountConcurrency, c.enableTLSFingerprint)
if err != nil {
return "", err
}
defer resp.Body.Close()
// 使用 LimitReader 限制最大响应大小,防止 DoS 攻击
limitedReader := io.LimitReader(resp.Body, maxAPIResponseSize+1)
data, err := io.ReadAll(limitedReader)
if err != nil {
return "", err
}
// 检查是否超过大小限制
if int64(len(data)) > maxAPIResponseSize {
return "", fmt.Errorf("API 响应过大 (最大 %d 字节)", maxAPIResponseSize)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("sentinel request failed: %d %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
return "", err
}
token := buildSentinelToken(reqID, powToken, parsed)
// 缓存结果
cacheSentinel(opts.AccountID, token)
return token, nil
}
func buildSentinelToken(reqID, powToken string, resp map[string]any) string {
finalPow := powToken
pow, _ := resp["proofofwork"].(map[string]any)
if pow != nil {
required, _ := pow["required"].(bool)
if required {
seed, _ := pow["seed"].(string)
difficulty, _ := pow["difficulty"].(string)
if seed != "" && difficulty != "" {
candidate, _ := solvePow(seed, difficulty, defaultDesktopUA)
if candidate != "" {
finalPow = "gAAAAAB" + candidate
}
}
}
}
if !strings.HasSuffix(finalPow, "~S") {
finalPow += "~S"
}
turnstile := ""
if t, ok := resp["turnstile"].(map[string]any); ok {
turnstile, _ = t["dx"].(string)
}
token := ""
if v, ok := resp["token"].(string); ok {
token = v
}
payload := map[string]any{
"p": finalPow,
"t": turnstile,
"c": token,
"id": reqID,
"flow": sentinelFlow,
}
data, _ := json.Marshal(payload)
return string(data)
}
func generatePowToken(userAgent string) (string, error) {
seed := fmt.Sprintf("%f", float64(time.Now().UnixNano())/1e9)
candidate, _ := solvePow(seed, "0fffff", userAgent)
if candidate == "" {
return "", errors.New("pow generation failed")
}
return "gAAAAAC" + candidate, nil
}
func solvePow(seed, difficulty, userAgent string) (string, bool) {
config := powConfig(userAgent)
seedBytes := []byte(seed)
diffBytes, err := hexDecode(difficulty)
if err != nil {
return "", false
}
configBytes, err := json.Marshal(config)
if err != nil {
return "", false
}
prefix := configBytes[:len(configBytes)-1]
for i := 0; i < 500000; i++ {
payload := append(prefix, []byte(fmt.Sprintf(",%d,%d]", i, i>>1))...)
b64 := base64.StdEncoding.EncodeToString(payload)
h := sha3.Sum512(append(seedBytes, []byte(b64)...))
if bytes.Compare(h[:len(diffBytes)], diffBytes) <= 0 {
return b64, true
}
}
return "", false
}
func powConfig(userAgent string) []any {
return []any{
3000,
formatPowTime(),
4294705152,
0,
userAgent,
"",
nil,
"en-US",
"en-US,es-US,en,es",
0,
"webdriver-false",
"location",
"window",
time.Now().UnixMilli(),
uuid.New().String(),
"",
16,
float64(time.Now().UnixMilli()),
}
}
func formatPowTime() string {
loc := time.FixedZone("EST", -5*60*60)
return time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05") + " GMT-0500 (Eastern Standard Time)"
}
func hexDecode(s string) ([]byte, error) {
if len(s)%2 != 0 {
return nil, errors.New("invalid hex length")
}
out := make([]byte, len(s)/2)
for i := 0; i < len(out); i++ {
byteVal, err := hexPair(s[i*2 : i*2+2])
if err != nil {
return nil, err
}
out[i] = byteVal
}
return out, nil
}
func hexPair(pair string) (byte, error) {
var v byte
for i := 0; i < 2; i++ {
c := pair[i]
var n byte
switch {
case c >= '0' && c <= '9':
n = c - '0'
case c >= 'a' && c <= 'f':
n = c - 'a' + 10
case c >= 'A' && c <= 'F':
n = c - 'A' + 10
default:
return 0, errors.New("invalid hex")
}
v = v<<4 | n
}
return v, nil
}
func stringFromJSON(data map[string]any, key string) string {
if data == nil {
return ""
}
if v, ok := data[key].(string); ok {
return v
}
return ""
}
func convertList(list []any) []map[string]any {
results := make([]map[string]any, 0, len(list))
for _, item := range list {
if m, ok := item.(map[string]any); ok {
results = append(results, m)
}
}
return results
}
func convertListFromAny(data map[string]any) []map[string]any {
if data == nil {
return nil
}
items, ok := data["items"].([]any)
if ok {
return convertList(items)
}
return nil
}

View File

@@ -0,0 +1,263 @@
package sora
// ModelConfig 定义 Sora 模型配置。
type ModelConfig struct {
Type string
Width int
Height int
Orientation string
NFrames int
Model string
Size string
RequirePro bool
ExpansionLevel string
DurationS int
}
// ModelConfigs 定义所有模型配置。
var ModelConfigs = map[string]ModelConfig{
"gpt-image": {
Type: "image",
Width: 360,
Height: 360,
},
"gpt-image-landscape": {
Type: "image",
Width: 540,
Height: 360,
},
"gpt-image-portrait": {
Type: "image",
Width: 360,
Height: 540,
},
"sora2-landscape-10s": {
Type: "video",
Orientation: "landscape",
NFrames: 300,
},
"sora2-portrait-10s": {
Type: "video",
Orientation: "portrait",
NFrames: 300,
},
"sora2-landscape-15s": {
Type: "video",
Orientation: "landscape",
NFrames: 450,
},
"sora2-portrait-15s": {
Type: "video",
Orientation: "portrait",
NFrames: 450,
},
"sora2-landscape-25s": {
Type: "video",
Orientation: "landscape",
NFrames: 750,
Model: "sy_8",
Size: "small",
RequirePro: true,
},
"sora2-portrait-25s": {
Type: "video",
Orientation: "portrait",
NFrames: 750,
Model: "sy_8",
Size: "small",
RequirePro: true,
},
"sora2pro-landscape-10s": {
Type: "video",
Orientation: "landscape",
NFrames: 300,
Model: "sy_ore",
Size: "small",
RequirePro: true,
},
"sora2pro-portrait-10s": {
Type: "video",
Orientation: "portrait",
NFrames: 300,
Model: "sy_ore",
Size: "small",
RequirePro: true,
},
"sora2pro-landscape-15s": {
Type: "video",
Orientation: "landscape",
NFrames: 450,
Model: "sy_ore",
Size: "small",
RequirePro: true,
},
"sora2pro-portrait-15s": {
Type: "video",
Orientation: "portrait",
NFrames: 450,
Model: "sy_ore",
Size: "small",
RequirePro: true,
},
"sora2pro-landscape-25s": {
Type: "video",
Orientation: "landscape",
NFrames: 750,
Model: "sy_ore",
Size: "small",
RequirePro: true,
},
"sora2pro-portrait-25s": {
Type: "video",
Orientation: "portrait",
NFrames: 750,
Model: "sy_ore",
Size: "small",
RequirePro: true,
},
"sora2pro-hd-landscape-10s": {
Type: "video",
Orientation: "landscape",
NFrames: 300,
Model: "sy_ore",
Size: "large",
RequirePro: true,
},
"sora2pro-hd-portrait-10s": {
Type: "video",
Orientation: "portrait",
NFrames: 300,
Model: "sy_ore",
Size: "large",
RequirePro: true,
},
"sora2pro-hd-landscape-15s": {
Type: "video",
Orientation: "landscape",
NFrames: 450,
Model: "sy_ore",
Size: "large",
RequirePro: true,
},
"sora2pro-hd-portrait-15s": {
Type: "video",
Orientation: "portrait",
NFrames: 450,
Model: "sy_ore",
Size: "large",
RequirePro: true,
},
"prompt-enhance-short-10s": {
Type: "prompt_enhance",
ExpansionLevel: "short",
DurationS: 10,
},
"prompt-enhance-short-15s": {
Type: "prompt_enhance",
ExpansionLevel: "short",
DurationS: 15,
},
"prompt-enhance-short-20s": {
Type: "prompt_enhance",
ExpansionLevel: "short",
DurationS: 20,
},
"prompt-enhance-medium-10s": {
Type: "prompt_enhance",
ExpansionLevel: "medium",
DurationS: 10,
},
"prompt-enhance-medium-15s": {
Type: "prompt_enhance",
ExpansionLevel: "medium",
DurationS: 15,
},
"prompt-enhance-medium-20s": {
Type: "prompt_enhance",
ExpansionLevel: "medium",
DurationS: 20,
},
"prompt-enhance-long-10s": {
Type: "prompt_enhance",
ExpansionLevel: "long",
DurationS: 10,
},
"prompt-enhance-long-15s": {
Type: "prompt_enhance",
ExpansionLevel: "long",
DurationS: 15,
},
"prompt-enhance-long-20s": {
Type: "prompt_enhance",
ExpansionLevel: "long",
DurationS: 20,
},
}
// ModelListItem 返回模型列表条目。
type ModelListItem struct {
ID string `json:"id"`
Object string `json:"object"`
OwnedBy string `json:"owned_by"`
Description string `json:"description"`
}
// ListModels 生成模型列表。
func ListModels() []ModelListItem {
models := make([]ModelListItem, 0, len(ModelConfigs))
for id, cfg := range ModelConfigs {
description := ""
switch cfg.Type {
case "image":
description = "Image generation"
if cfg.Width > 0 && cfg.Height > 0 {
description += " - " + itoa(cfg.Width) + "x" + itoa(cfg.Height)
}
case "video":
description = "Video generation"
if cfg.Orientation != "" {
description += " - " + cfg.Orientation
}
case "prompt_enhance":
description = "Prompt enhancement"
if cfg.ExpansionLevel != "" {
description += " - " + cfg.ExpansionLevel
}
if cfg.DurationS > 0 {
description += " (" + itoa(cfg.DurationS) + "s)"
}
default:
description = "Sora model"
}
models = append(models, ModelListItem{
ID: id,
Object: "model",
OwnedBy: "sora",
Description: description,
})
}
return models
}
func itoa(val int) string {
if val == 0 {
return "0"
}
neg := false
if val < 0 {
neg = true
val = -val
}
buf := [12]byte{}
i := len(buf)
for val > 0 {
i--
buf[i] = byte('0' + val%10)
val /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

View File

@@ -0,0 +1,63 @@
package sora
import (
"regexp"
"strings"
)
var storyboardRe = regexp.MustCompile(`\[(\d+(?:\.\d+)?)s\]`)
// IsStoryboardPrompt 检测是否为分镜提示词。
func IsStoryboardPrompt(prompt string) bool {
if strings.TrimSpace(prompt) == "" {
return false
}
return storyboardRe.MatchString(prompt)
}
// FormatStoryboardPrompt 将分镜提示词转换为 API 需要的格式。
func FormatStoryboardPrompt(prompt string) string {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return prompt
}
matches := storyboardRe.FindAllStringSubmatchIndex(prompt, -1)
if len(matches) == 0 {
return prompt
}
firstIdx := matches[0][0]
instructions := strings.TrimSpace(prompt[:firstIdx])
shotPattern := regexp.MustCompile(`\[(\d+(?:\.\d+)?)s\]\s*([^\[]+)`)
shotMatches := shotPattern.FindAllStringSubmatch(prompt, -1)
if len(shotMatches) == 0 {
return prompt
}
shots := make([]string, 0, len(shotMatches))
for i, sm := range shotMatches {
if len(sm) < 3 {
continue
}
duration := strings.TrimSpace(sm[1])
scene := strings.TrimSpace(sm[2])
shots = append(shots, "Shot "+itoa(i+1)+":\nduration: "+duration+"sec\nScene: "+scene)
}
timeline := strings.Join(shots, "\n\n")
if instructions != "" {
return "current timeline:\n" + timeline + "\n\ninstructions:\n" + instructions
}
return timeline
}
// ExtractRemixID 提取分享链接中的 remix ID。
func ExtractRemixID(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
re := regexp.MustCompile(`s_[a-f0-9]{32}`)
match := re.FindString(text)
return match
}

View File

@@ -0,0 +1,31 @@
package uuidv7
import (
"crypto/rand"
"fmt"
"time"
)
// New returns a UUIDv7 string.
func New() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
ms := uint64(time.Now().UnixMilli())
b[0] = byte(ms >> 40)
b[1] = byte(ms >> 32)
b[2] = byte(ms >> 24)
b[3] = byte(ms >> 16)
b[4] = byte(ms >> 8)
b[5] = byte(ms)
b[6] = (b[6] & 0x0f) | 0x70
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
uint32(b[0])<<24|uint32(b[1])<<16|uint32(b[2])<<8|uint32(b[3]),
uint16(b[4])<<8|uint16(b[5]),
uint16(b[6])<<8|uint16(b[7]),
uint16(b[8])<<8|uint16(b[9]),
uint64(b[10])<<40|uint64(b[11])<<32|uint64(b[12])<<24|uint64(b[13])<<16|uint64(b[14])<<8|uint64(b[15]),
), nil
}