fix: comprehensive Sora & GDrive code review fixes

P0 fixes:
- StorageType: UploadFromURL now returns actual storageType, fixing
  GDrive tasks being incorrectly recorded as "s3"
- Concurrency: selectAccount returns ReleaseFunc to caller, fixing
  premature slot release before task creation
- Race condition: applyRecoveredToken uses copy-on-write for
  account.Credentials map to prevent concurrent write panic
- GetCameoStatus: use direct HTTP call to populate missing fields
  (StatusMessage, InstructionSetHint, InstructionSet)
- Frontend: progress bar uses reactive now.value instead of
  non-reactive Date.now()

P1 fixes:
- EditImage: pass req.Image through to CreateImageRequest
- ListPending: add LIMIT 200 to prevent unbounded queries
- Worker encapsulation: add ListPendingTasks/UpdateTask proxy methods
  to SoraTaskService, worker no longer accesses repo directly
- Frontend polling: add retry counter (3 attempts) before giving up

P2-P3 fixes:
- QuotaBar: handle both quota_source and source field names
- i18n: replace hardcoded Chinese labels in SoraPromptBar and
  example prompts in SoraGeneratePage with i18n keys
- Fix regenrate → regenerate i18n typo
- SoraMediaPreview: skip Escape handler when not visible
- SoraGeneratePage: void handleGenerate promise in handleRetry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-08 01:02:55 +08:00
co-authored by Claude Opus 4.6
parent f9695ca83f
commit 597c19e80b
18 changed files with 168 additions and 81 deletions
@@ -350,9 +350,10 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
if h.objectStorage != nil && h.objectStorage.Enabled(ctx) {
keys := make([]string, 0, len(urls))
var totalSize int64
var actualStorageType string
allOK := true
for _, u := range urls {
key, size, err := h.objectStorage.UploadFromURL(ctx, userID, u)
key, size, st, err := h.objectStorage.UploadFromURL(ctx, userID, u)
if err != nil {
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 对象存储上传失败 type=%s err=%v", h.objectStorage.StorageType(), err)
allOK = false
@@ -364,6 +365,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
}
keys = append(keys, key)
totalSize += size
actualStorageType = st
}
if allOK && len(keys) > 0 {
accessURLs := make([]string, 0, len(keys))
@@ -378,7 +380,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
accessURLs = append(accessURLs, accessURL)
}
if allOK && len(accessURLs) > 0 {
return accessURLs[0], accessURLs, h.objectStorage.StorageType(), keys, totalSize
return accessURLs[0], accessURLs, actualStorageType, keys, totalSize
}
}
}
@@ -697,7 +699,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
var totalSize int64
for _, sourceURL := range sourceURLs {
objectKey, fileSize, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL)
objectKey, fileSize, _, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL)
if uploadErr != nil {
if len(uploadedKeys) > 0 {
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
@@ -43,10 +43,11 @@ func NewSoraVideosHandler(
}
func (h *SoraVideosHandler) CreateVideo(c *gin.Context) {
apiKey, account, ok := h.selectAccount(c, "")
apiKey, account, release, ok := h.selectAccount(c, "")
if !ok {
return
}
defer release()
body, err := readBody(c)
if err != nil {
@@ -224,10 +225,11 @@ func (h *SoraVideosHandler) resolveContentURL(c *gin.Context, task *service.Sora
}
func (h *SoraVideosHandler) CreateImage(c *gin.Context) {
apiKey, account, ok := h.selectAccount(c, "")
apiKey, account, release, ok := h.selectAccount(c, "")
if !ok {
return
}
defer release()
body, err := readBody(c)
if err != nil {
@@ -257,10 +259,11 @@ func (h *SoraVideosHandler) CreateImage(c *gin.Context) {
}
func (h *SoraVideosHandler) EditImage(c *gin.Context) {
apiKey, account, ok := h.selectAccount(c, "")
apiKey, account, release, ok := h.selectAccount(c, "")
if !ok {
return
}
defer release()
body, err := readBody(c)
if err != nil {
@@ -287,6 +290,7 @@ func (h *SoraVideosHandler) EditImage(c *gin.Context) {
imageReq := &service.CreateImageRequest{
Model: req.Model,
Prompt: req.Prompt,
Image: req.Image,
Size: req.Size,
ResponseFormat: req.ResponseFormat,
N: 1,
@@ -312,10 +316,10 @@ func (h *SoraVideosHandler) getAPIKey(c *gin.Context) (*service.APIKey, bool) {
return apiKey, true
}
func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*service.APIKey, *service.Account, bool) {
func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*service.APIKey, *service.Account, func(), bool) {
apiKey, ok := h.getAPIKey(c)
if !ok {
return nil, nil, false
return nil, nil, nil, false
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(
@@ -323,12 +327,14 @@ func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*servic
)
if err != nil {
soraErrorResponse(c, http.StatusServiceUnavailable, "server_error", "No available accounts")
return nil, nil, false
return nil, nil, nil, false
}
releaseFunc := func() {}
if selection.ReleaseFunc != nil {
defer selection.ReleaseFunc()
releaseFunc = selection.ReleaseFunc
}
return apiKey, selection.Account, true
return apiKey, selection.Account, releaseFunc, true
}
func (h *SoraVideosHandler) selectAccountByID(c *gin.Context, accountID int64) (*service.Account, error) {
@@ -85,7 +85,7 @@ func (r *SoraTaskRepository) Update(ctx context.Context, task *service.SoraTask)
func (r *SoraTaskRepository) ListPending(ctx context.Context) ([]*service.SoraTask, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+soraTaskColumns+` FROM sora_tasks WHERE status IN ('queued', 'in_progress') ORDER BY created_at ASC`,
`SELECT `+soraTaskColumns+` FROM sora_tasks WHERE status IN ('queued', 'in_progress') ORDER BY created_at ASC LIMIT 200`,
)
if err != nil {
return nil, err
@@ -297,29 +297,29 @@ func (s *SoraGDriveStorage) IsHealthy(ctx context.Context) bool {
}
// UploadFromURL 从上游 URL 下载并上传到 Google Drive。
// 返回 Google Drive 文件 ID 作为 objectKey。
func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, error) {
// 返回 Google Drive 文件 ID 作为 objectKey、文件大小、存储类型。
func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
srv, cfg, err := s.getService(ctx)
if err != nil {
return "", 0, err
return "", 0, "", err
}
// 下载源文件
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return "", 0, fmt.Errorf("create download request: %w", err)
return "", 0, "", fmt.Errorf("create download request: %w", err)
}
httpClient := &http.Client{Timeout: 5 * time.Minute}
resp, err := httpClient.Do(req)
if err != nil {
return "", 0, fmt.Errorf("download from upstream: %w", err)
return "", 0, "", fmt.Errorf("download from upstream: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return "", 0, &UpstreamDownloadError{StatusCode: resp.StatusCode}
return "", 0, "", &UpstreamDownloadError{StatusCode: resp.StatusCode}
}
// 推断文件扩展名和 MIME
@@ -358,7 +358,7 @@ func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sou
Context(ctx).
Do()
if err != nil {
return "", 0, fmt.Errorf("gdrive upload: %w", err)
return "", 0, "", fmt.Errorf("gdrive upload: %w", err)
}
fileSize := cr.BytesRead
@@ -379,7 +379,7 @@ func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sou
}
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 上传完成 fileID=%s size=%d", created.Id, fileSize)
return created.Id, fileSize, nil
return created.Id, fileSize, SoraStorageTypeGDrive, nil
}
// DeleteObjects 删除一组 Google Drive 文件。
@@ -15,8 +15,8 @@ type SoraObjectStorage interface {
TestConnection(ctx context.Context) error
// UploadFromURL 从上游 URL 下载并上传到存储。
// 返回 object key(S3 key 或 GDrive file ID)、文件大小。
UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, err error)
// 返回 object key(S3 key 或 GDrive file ID)、文件大小、实际使用的存储类型。
UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, storageType string, err error)
// DeleteObjects 删除一组存储对象。
DeleteObjects(ctx context.Context, objectKeys []string) error
+9 -9
View File
@@ -212,29 +212,29 @@ func (s *SoraS3Storage) GenerateObjectKey(prefix string, userID int64, ext strin
}
// UploadFromURL 从上游 URL 下载并流式上传到 S3。
// 返回 S3 object key。
func (s *SoraS3Storage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, error) {
// 返回 S3 object key、文件大小、存储类型。
func (s *SoraS3Storage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
client, cfg, err := s.getClient(ctx)
if err != nil {
return "", 0, err
return "", 0, "", err
}
// 下载源文件
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return "", 0, fmt.Errorf("create download request: %w", err)
return "", 0, "", fmt.Errorf("create download request: %w", err)
}
httpClient := &http.Client{Timeout: 5 * time.Minute}
resp, err := httpClient.Do(req)
if err != nil {
return "", 0, fmt.Errorf("download from upstream: %w", err)
return "", 0, "", fmt.Errorf("download from upstream: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return "", 0, &UpstreamDownloadError{StatusCode: resp.StatusCode}
return "", 0, "", &UpstreamDownloadError{StatusCode: resp.StatusCode}
}
// 推断文件扩展名
@@ -275,14 +275,14 @@ func (s *SoraS3Storage) UploadFromURL(ctx context.Context, userID int64, sourceU
_ = writer.CloseWithError(copyErr)
uploadErr := <-uploadErrCh
if copyErr != nil {
return "", 0, fmt.Errorf("stream upload copy failed: %w", copyErr)
return "", 0, "", fmt.Errorf("stream upload copy failed: %w", copyErr)
}
if uploadErr != nil {
return "", 0, fmt.Errorf("s3 upload: %w", uploadErr)
return "", 0, "", fmt.Errorf("s3 upload: %w", uploadErr)
}
logger.LegacyPrintf("service.sora_s3", "[SoraS3] 上传完成 key=%s size=%d", objectKey, written)
return objectKey, written, nil
return objectKey, written, SoraStorageTypeS3, nil
}
func buildSoraS3Client(ctx context.Context, cfg *SoraS3Settings) (*s3.Client, string, error) {
@@ -238,7 +238,7 @@ func TestTestConnection_GetClientError(t *testing.T) {
func TestUploadFromURL_GetClientError(t *testing.T) {
s := NewSoraS3Storage(nil)
_, _, err := s.UploadFromURL(context.Background(), 1, "https://example.com/file.mp4")
_, _, _, err := s.UploadFromURL(context.Background(), 1, "https://example.com/file.mp4")
require.Error(t, err)
}
+40 -18
View File
@@ -316,20 +316,36 @@ func (c *SoraSDKClient) GetCameoStatus(ctx context.Context, account *Account, ca
if err != nil {
return nil, err
}
sdkClient, err := c.getSDKClient(account)
if err != nil {
return nil, err
}
status, err := sdkClient.GetCameoStatus(ctx, token, cameoID)
// 直接调用 Sora 后端 API 而非 SDK,以获取 SDK 未暴露的字段
// (status_message、instruction_set_hint、instruction_set)。
path := "/project_y/cameos/in_progress/" + cameoID
raw, err := c.doSoraBackendJSON(ctx, account, http.MethodGet, path, token, "", nil)
if err != nil {
return nil, c.wrapSDKError(err, account)
}
return &SoraCameoStatus{
Status: status.Status,
DisplayNameHint: status.DisplayNameHint,
UsernameHint: status.UsernameHint,
ProfileAssetURL: status.ProfileAssetURL,
}, nil
return parseCameoStatusFromRaw(raw), nil
}
// parseCameoStatusFromRaw 从原始 JSON 解析 SoraCameoStatus,
// 包含 SDK 未暴露的 status_message / instruction_set_hint / instruction_set 字段。
func parseCameoStatusFromRaw(raw []byte) *SoraCameoStatus {
result := gjson.ParseBytes(raw)
cameoStatus := &SoraCameoStatus{
Status: strings.TrimSpace(result.Get("status").String()),
StatusMessage: strings.TrimSpace(result.Get("status_message").String()),
DisplayNameHint: strings.TrimSpace(result.Get("display_name_hint").String()),
UsernameHint: strings.TrimSpace(result.Get("username_hint").String()),
ProfileAssetURL: strings.TrimSpace(result.Get("profile_asset_url").String()),
}
if v := result.Get("instruction_set_hint"); v.Exists() {
cameoStatus.InstructionSetHint = v.Value()
}
if v := result.Get("instruction_set"); v.Exists() {
cameoStatus.InstructionSet = v.Value()
}
return cameoStatus
}
func (c *SoraSDKClient) DownloadCharacterImage(ctx context.Context, account *Account, imageURL string) ([]byte, error) {
@@ -925,26 +941,32 @@ func (c *SoraSDKClient) exchangeSessionToken(ctx context.Context, account *Accou
return accessToken, expiresAt, nil
}
// applyRecoveredToken 将恢复的 token 写入账号内存和数据库
// applyRecoveredToken 将恢复的 token 写入账号内存和数据库。
// 使用 copy-on-write 避免并发 map 写入 panic:创建新 map 后整体替换指针。
func (c *SoraSDKClient) applyRecoveredToken(ctx context.Context, account *Account, accessToken, refreshToken, expiresAt, sessionToken string) {
if account == nil {
return
}
if account.Credentials == nil {
account.Credentials = make(map[string]any)
// Copy-on-write: 复制旧 map 并写入新值,最后整体替换
oldCreds := account.Credentials
newCreds := make(map[string]any, len(oldCreds)+4)
for k, v := range oldCreds {
newCreds[k] = v
}
if strings.TrimSpace(accessToken) != "" {
account.Credentials["access_token"] = accessToken
newCreds["access_token"] = accessToken
}
if strings.TrimSpace(refreshToken) != "" {
account.Credentials["refresh_token"] = refreshToken
newCreds["refresh_token"] = refreshToken
}
if strings.TrimSpace(expiresAt) != "" {
account.Credentials["expires_at"] = expiresAt
newCreds["expires_at"] = expiresAt
}
if strings.TrimSpace(sessionToken) != "" {
account.Credentials["session_token"] = sessionToken
newCreds["session_token"] = sessionToken
}
account.Credentials = newCreds
if c.accountRepo != nil {
if err := c.accountRepo.Update(ctx, account); err != nil && c.debugEnabled() {
@@ -75,10 +75,10 @@ func (r *SoraStorageRouter) TestConnection(ctx context.Context) error {
return backend.TestConnection(ctx)
}
func (r *SoraStorageRouter) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, error) {
func (r *SoraStorageRouter) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
backend := r.activeBackend(ctx)
if backend == nil {
return "", 0, fmt.Errorf("no storage backend available")
return "", 0, "", fmt.Errorf("no storage backend available")
}
return backend.UploadFromURL(ctx, userID, sourceURL)
}
@@ -53,6 +53,7 @@ type CreateVideoRequest struct {
type CreateImageRequest struct {
Model string `json:"model,omitempty"`
Prompt string `json:"prompt"`
Image string `json:"image,omitempty"`
Size string `json:"size,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
N int `json:"n,omitempty"`
@@ -262,6 +263,16 @@ func (s *SoraTaskService) GetTaskByID(ctx context.Context, taskID string) (*Sora
return s.repo.GetByID(ctx, taskID)
}
// ListPendingTasks returns tasks in queued or in_progress status.
func (s *SoraTaskService) ListPendingTasks(ctx context.Context) ([]*SoraTask, error) {
return s.repo.ListPending(ctx)
}
// UpdateTask persists task state changes.
func (s *SoraTaskService) UpdateTask(ctx context.Context, task *SoraTask) error {
return s.repo.Update(ctx, task)
}
func (s *SoraTaskService) GetAccountByID(ctx context.Context, accountID int64) (*Account, error) {
return s.accountRepo.GetByID(ctx, accountID)
}
+6 -6
View File
@@ -81,7 +81,7 @@ func (w *SoraTaskWorker) pollAll() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
tasks, err := w.taskService.repo.ListPending(ctx)
tasks, err := w.taskService.ListPendingTasks(ctx)
if err != nil {
logger.LegacyPrintf("service.sora_task_worker", "[PollAll] list pending tasks error: %v", err)
return
@@ -137,14 +137,14 @@ func (w *SoraTaskWorker) tryStoreMedia(ctx context.Context, task *SoraTask) {
}
if w.objectStorage != nil && w.objectStorage.Enabled(ctx) {
key, _, err := w.objectStorage.UploadFromURL(ctx, 0, task.VideoURL)
key, _, storageType, err := w.objectStorage.UploadFromURL(ctx, 0, task.VideoURL)
if err != nil {
logger.LegacyPrintf("service.sora_task_worker",
"[StoreMedia] task=%s object storage upload error: %v", task.ID, err)
} else {
task.StoredKey = key
task.StorageType = w.objectStorage.StorageType()
if updateErr := w.taskService.repo.Update(ctx, task); updateErr != nil {
task.StorageType = storageType
if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
logger.LegacyPrintf("service.sora_task_worker",
"[StoreMedia] task=%s update stored_key error: %v", task.ID, updateErr)
}
@@ -162,7 +162,7 @@ func (w *SoraTaskWorker) tryStoreMedia(ctx context.Context, task *SoraTask) {
if len(stored) > 0 && stored[0] != task.VideoURL {
task.StoredKey = stored[0]
task.StorageType = "local"
if updateErr := w.taskService.repo.Update(ctx, task); updateErr != nil {
if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
logger.LegacyPrintf("service.sora_task_worker",
"[StoreMedia] task=%s update stored_key error: %v", task.ID, updateErr)
}
@@ -176,7 +176,7 @@ func (w *SoraTaskWorker) markTaskFailed(ctx context.Context, task *SoraTask, mes
task.ErrorMessage = message
task.ErrorType = "server_error"
task.CompletedAt = &now
if updateErr := w.taskService.repo.Update(ctx, task); updateErr != nil {
if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
logger.LegacyPrintf("service.sora_task_worker",
"[PollOne] task=%s update failed: %v", task.ID, updateErr)
}
@@ -67,15 +67,16 @@ const activeGenerations = ref<SoraGeneration[]>([])
const generating = ref(false)
const showNoStorageToast = ref(false)
let pollTimers: Record<number, ReturnType<typeof setTimeout>> = {}
let pollRetries: Record<number, number> = {}
const promptBarRef = ref<InstanceType<typeof SoraPromptBar> | null>(null)
// 示例提示词
const examplePrompts = [
'一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
'无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
'赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
'水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
]
const examplePrompts = computed(() => [
t('sora.examplePrompt1'),
t('sora.examplePrompt2'),
t('sora.examplePrompt3'),
t('sora.examplePrompt4')
])
// 活跃任务统计
const activeTaskCount = computed(() =>
@@ -189,13 +190,22 @@ async function pollGeneration(id: number) {
checkStatusTransition(activeGenerations.value[idx], gen)
activeGenerations.value[idx] = gen
}
pollRetries[id] = 0
if (gen.status === 'pending' || gen.status === 'generating') {
schedulePolling(id)
} else {
delete pollTimers[id]
delete pollRetries[id]
}
} catch {
delete pollTimers[id]
const retries = (pollRetries[id] || 0) + 1
pollRetries[id] = retries
if (retries > 3) {
delete pollTimers[id]
delete pollRetries[id]
} else {
schedulePolling(id)
}
}
}
@@ -265,7 +275,7 @@ async function handleSave(id: number) {
}
function handleRetry(gen: SoraGeneration) {
handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
void handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
}
function fillPrompt(text: string) {
@@ -296,6 +306,7 @@ onMounted(() => {
onUnmounted(() => {
Object.values(pollTimers).forEach(clearTimeout)
pollTimers = {}
pollRetries = {}
stopTitleBlink()
window.removeEventListener('beforeunload', beforeUnloadHandler)
})
@@ -82,7 +82,7 @@ import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
defineProps<{
const props = defineProps<{
visible: boolean
generation: SoraGeneration | null
}>()
@@ -100,6 +100,7 @@ function formatDateTime(iso: string): string {
}
function handleKeydown(e: KeyboardEvent) {
if (!props.visible) return
if (e.key === 'Escape') emit('close')
}
@@ -114,7 +114,7 @@
<!-- 失败/取消 -->
<template v-if="generation.status === 'failed' || generation.status === 'cancelled'">
<button class="sora-action-btn primary" @click="emit('retry', generation)">
🔄 {{ generation.status === 'cancelled' ? t('sora.regenrate') : t('sora.retry') }}
🔄 {{ generation.status === 'cancelled' ? t('sora.regenerate') : t('sora.retry') }}
</button>
<button class="sora-action-btn secondary" @click="emit('delete', generation.id)">
🗑 {{ t('sora.delete') }}
@@ -194,7 +194,7 @@ const progressWidth = computed(() => {
if (s === 'generating') {
// 根据创建时间估算进度
const created = new Date(props.generation.created_at).getTime()
const elapsed = Date.now() - created
const elapsed = now.value - created
// 假设平均 10 分钟完成,最多到 95%
const progress = Math.min(95, (elapsed / (10 * 60 * 1000)) * 100)
return `${Math.round(progress)}%`
@@ -207,7 +207,7 @@ const progressInfoText = computed(() => {
if (s === 'pending') return t('sora.queueWaiting')
if (s === 'generating') {
const created = new Date(props.generation.created_at).getTime()
const elapsed = Date.now() - created
const elapsed = now.value - created
return `${t('sora.waited')} ${formatElapsed(elapsed)}`
}
return ''
@@ -262,11 +262,19 @@ const countdownText = computed(() => {
return `${m}:${s.toString().padStart(2, '0')}`
})
const needsTimer = computed(() => {
const s = props.generation.status
return s === 'pending' || s === 'generating' || isUpstream.value
})
onMounted(() => {
if (isUpstream.value) {
if (needsTimer.value) {
countdownTimer = setInterval(() => {
now.value = Date.now()
if (now.value >= expireTime.value && countdownTimer) {
// Stop timer when upstream countdown expires and no longer generating
const s = props.generation.status
const stillGenerating = s === 'pending' || s === 'generating'
if (!stillGenerating && isUpstream.value && now.value >= expireTime.value && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
+16 -6
View File
@@ -167,11 +167,17 @@ import type { ApiKey, UserSubscription } from '@/types'
const MAX_IMAGE_SIZE = 20 * 1024 * 1024
/** 方向显示配置 */
const ASPECT_META: Record<string, { icon: string; label: string }> = {
landscape: { icon: '▬', label: '横屏' },
portrait: { icon: '▮', label: '竖屏' },
square: { icon: '◻', label: '方形' }
/** 方向显示配置(使用 i18n key) */
const ASPECT_ICONS: Record<string, string> = {
landscape: '▬',
portrait: '▮',
square: '◻'
}
const ASPECT_LABEL_KEYS: Record<string, string> = {
landscape: 'sora.landscape',
portrait: 'sora.portrait',
square: 'sora.square'
}
const props = defineProps<{
@@ -221,7 +227,11 @@ const availableAspects = computed(() => {
const fam = currentFamily.value
if (!fam?.orientations?.length) return []
return fam.orientations
.map(o => ({ value: o, ...(ASPECT_META[o] || { icon: '?', label: o }) }))
.map(o => ({
value: o,
icon: ASPECT_ICONS[o] || '?',
label: ASPECT_LABEL_KEYS[o] ? t(ASPECT_LABEL_KEYS[o]) : o
}))
})
// 当前家族支持的时长列表
@@ -1,5 +1,5 @@
<template>
<div v-if="quota && quota.source !== 'none'" class="sora-quota-info">
<div v-if="quota && (quota.quota_source || quota.source) !== 'none'" class="sora-quota-info">
<div class="sora-quota-bar-wrapper">
<div
class="sora-quota-bar-fill"
+9 -1
View File
@@ -4464,6 +4464,7 @@ export default {
downloadLocal: 'Download',
canDownload: 'to download',
regenrate: 'Regenerate',
regenerate: 'Regenerate',
creatorPlaceholder: 'Describe the video or image you want to create...',
videoModels: 'Video Models',
imageModels: 'Image Models',
@@ -4480,6 +4481,13 @@ export default {
galleryEmptyTitle: 'No works yet',
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
startCreating: 'Start Creating',
yesterday: 'Yesterday'
yesterday: 'Yesterday',
landscape: 'Landscape',
portrait: 'Portrait',
square: 'Square',
examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K',
examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in',
examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors',
examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere'
}
}
+9 -1
View File
@@ -4655,6 +4655,7 @@ export default {
downloadLocal: '本地下载',
canDownload: '可下载',
regenrate: '重新生成',
regenerate: '重新生成',
creatorPlaceholder: '描述你想要生成的视频或图片...',
videoModels: '视频模型',
imageModels: '图片模型',
@@ -4671,6 +4672,13 @@ export default {
galleryEmptyTitle: '还没有任何作品',
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
startCreating: '开始创作',
yesterday: '昨天'
yesterday: '昨天',
landscape: '横屏',
portrait: '竖屏',
square: '方形',
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
}
}