1
This commit is contained in:
@@ -94,6 +94,88 @@ describe('assetReadUrlService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl normalizes generated object key without leading slash', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
signedUrl: 'https://signed.example.com/puzzle.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveAssetReadUrl(
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
),
|
||||
).resolves.toBe('https://signed.example.com/puzzle.png');
|
||||
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl does not append cache busting query to OSS signed url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
signedUrl:
|
||||
'https://bucket.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle.png?x-oss-signature=abc&x-oss-expires=600',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveAssetReadUrl(
|
||||
'/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
{ refreshKey: 'latest-result' },
|
||||
),
|
||||
).resolves.toBe(
|
||||
'https://bucket.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle.png?x-oss-signature=abc&x-oss-expires=600',
|
||||
);
|
||||
});
|
||||
|
||||
test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
|
||||
|
||||
@@ -11,8 +11,9 @@ type AssetReadUrlResolveOptions = {
|
||||
expireSeconds?: number;
|
||||
/**
|
||||
* 图片内容可能在同一路径下被重新写入。
|
||||
* 这时需要显式跳过本地签名缓存,并在最终 URL 上追加一次性参数,
|
||||
* 避免结果页仍命中旧签名地址或浏览器图片缓存。
|
||||
* 对 generated 私有资源只跳过本地签名缓存并重新换签,
|
||||
* 不能给 OSS V4 签名 URL 追加一次性参数。
|
||||
* 普通非签名 URL 仍可追加 `_v` 避免浏览器图片缓存。
|
||||
*/
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
@@ -45,7 +46,7 @@ const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
||||
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
||||
|
||||
export function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
}
|
||||
|
||||
function normalizeLegacyPublicPath(value: string) {
|
||||
@@ -219,8 +220,17 @@ function appendCacheBustParam(
|
||||
return url;
|
||||
}
|
||||
|
||||
// OSS V4 签名会把 query 纳入签名计算,前端不能追加 `_v` 之类的缓存参数。
|
||||
// 需要刷新时通过 refreshKey 绕过本地签名缓存,重新请求 `/api/assets/read-url`。
|
||||
if (/[?&]x-oss-signature(?:=|&|$)/u.test(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url, globalThis.location?.origin ?? 'http://localhost');
|
||||
if (parsedUrl.searchParams.has('x-oss-signature')) {
|
||||
return url;
|
||||
}
|
||||
parsedUrl.searchParams.set('_v', normalizedRefreshKey);
|
||||
if (/^(?:https?:)?\/\//u.test(url)) {
|
||||
return parsedUrl.toString();
|
||||
@@ -262,7 +272,7 @@ export async function resolveAssetReadUrl(
|
||||
options.refreshKey !== null && options.refreshKey !== undefined,
|
||||
},
|
||||
);
|
||||
return appendCacheBustParam(signedUrl, options.refreshKey);
|
||||
return signedUrl;
|
||||
}
|
||||
|
||||
return appendCacheBustParam(value, options.refreshKey);
|
||||
|
||||
@@ -26,8 +26,53 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
expect(progress?.phaseLabel).toBe('编译首关草稿');
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'根据画面描述生成首关名称和结果页草稿。',
|
||||
'理解画面描述,生成首关名称与可编辑草稿。',
|
||||
);
|
||||
expect(progress?.estimatedRemainingMs).toBe(59_500);
|
||||
expect(progress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('puzzle draft generation advances steps across the 60 second estimate', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const imageProgress = buildMiniGameDraftGenerationProgress(state, 16_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 56_000);
|
||||
|
||||
expect(imageProgress?.phaseId).toBe('puzzle-images');
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(45_000);
|
||||
expect(imageProgress?.steps[0]?.status).toBe('completed');
|
||||
expect(imageProgress?.steps[1]?.status).toBe('active');
|
||||
expect(imageProgress?.steps[1]?.completed).toBeGreaterThan(0);
|
||||
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
|
||||
expect(writeBackProgress?.steps[1]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[2]?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('puzzle draft generation keeps moving without claiming completion before response', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 80_000);
|
||||
|
||||
expect(progress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(progress?.overallProgress).toBe(98);
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps[2]?.completed).toBe(1);
|
||||
});
|
||||
|
||||
test('puzzle ready copy points to result page work info completion', () => {
|
||||
|
||||
@@ -53,23 +53,37 @@ const PUZZLE_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译首关草稿',
|
||||
detail: '根据画面描述生成首关名称和结果页草稿。',
|
||||
weight: 34,
|
||||
detail: '理解画面描述,生成首关名称与可编辑草稿。',
|
||||
weight: 20,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-images',
|
||||
label: '生成首关画面',
|
||||
detail: '按画面描述和参考图生成第一张拼图图。',
|
||||
weight: 33,
|
||||
detail: '调用图片模型生成适合切块的正方形首图。',
|
||||
weight: 70,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '写入正式草稿',
|
||||
detail: '把首图设为正式图并同步到结果页。',
|
||||
weight: 33,
|
||||
detail: '确认首图并同步关卡数据,准备进入结果页。',
|
||||
weight: 10,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const PUZZLE_ESTIMATED_WAIT_MS = 60_000;
|
||||
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
|
||||
const PUZZLE_PHASE_TIMELINE: Array<{
|
||||
phase: Extract<
|
||||
MiniGameDraftGenerationPhase,
|
||||
'compile' | 'puzzle-images' | 'puzzle-select-image'
|
||||
>;
|
||||
durationMs: number;
|
||||
}> = [
|
||||
{ phase: 'compile', durationMs: 12_000 },
|
||||
{ phase: 'puzzle-images', durationMs: 42_000 },
|
||||
{ phase: 'puzzle-select-image', durationMs: 6_000 },
|
||||
];
|
||||
|
||||
const BIG_FISH_STEPS = [
|
||||
{
|
||||
id: 'big-fish-draft',
|
||||
@@ -141,6 +155,7 @@ function buildMiniGameProgressSteps(
|
||||
steps: ReadonlyArray<MiniGameStepDefinition>,
|
||||
activeStepIndex: number,
|
||||
state: MiniGameDraftGenerationState,
|
||||
activeStepProgressRatio: number,
|
||||
) {
|
||||
return steps.map((step, index) => {
|
||||
const isCompleted = state.phase === 'ready' || index < activeStepIndex;
|
||||
@@ -152,7 +167,13 @@ function buildMiniGameProgressSteps(
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted ? 1 : isAssetStep ? state.completedAssetCount : 0,
|
||||
completed: isCompleted
|
||||
? 1
|
||||
: isAssetStep
|
||||
? state.completedAssetCount
|
||||
: isActive
|
||||
? activeStepProgressRatio
|
||||
: 0,
|
||||
total: isAssetStep ? state.totalAssetCount : 1,
|
||||
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
@@ -201,6 +222,31 @@ function resolveSquareHolePhaseByElapsedMs(
|
||||
return 'square-hole-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
for (const item of PUZZLE_PHASE_TIMELINE) {
|
||||
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
|
||||
|
||||
if (elapsedInPhase < item.durationMs) {
|
||||
return {
|
||||
phase: item.phase,
|
||||
activeStepProgressRatio: Math.max(
|
||||
0,
|
||||
Math.min(1, elapsedInPhase / item.durationMs),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
elapsedBeforePhase += item.durationMs;
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'puzzle-select-image' as const,
|
||||
activeStepProgressRatio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMiniGameDraftGenerationProgress(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
nowMs = Date.now(),
|
||||
@@ -210,8 +256,19 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(0, nowMs - state.startedAtMs);
|
||||
const puzzleTimeline =
|
||||
state.kind === 'puzzle' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
|
||||
: null;
|
||||
const normalizedState =
|
||||
state.kind === 'big-fish' &&
|
||||
puzzleTimeline != null
|
||||
? {
|
||||
...state,
|
||||
phase: puzzleTimeline.phase,
|
||||
}
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
@@ -244,6 +301,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 1
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
@@ -255,6 +314,12 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
: normalizedState.phase === 'ready'
|
||||
? 100
|
||||
: completedWeight + activeStep.weight * assetRatio;
|
||||
const cappedOverallProgress =
|
||||
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
|
||||
? overallProgress
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
|
||||
: overallProgress;
|
||||
|
||||
return {
|
||||
phaseId: normalizedState.phase,
|
||||
@@ -272,20 +337,27 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(overallProgress),
|
||||
completedWeight: clampProgress(overallProgress),
|
||||
overallProgress: clampProgress(cappedOverallProgress),
|
||||
completedWeight: clampProgress(cappedOverallProgress),
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
normalizedState.phase === 'ready'
|
||||
? 0
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState),
|
||||
steps: buildMiniGameProgressSteps(
|
||||
steps,
|
||||
activeStepIndex,
|
||||
normalizedState,
|
||||
assetRatio,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './visualNovelCreationClient';
|
||||
export * from './visualNovelAssetClient';
|
||||
export * from './visualNovelAudioGenerationClient';
|
||||
|
||||
@@ -6,7 +6,8 @@ export type VisualNovelUploadAssetKind =
|
||||
| 'cover'
|
||||
| 'scene_background'
|
||||
| 'character_standee'
|
||||
| 'music';
|
||||
| 'music'
|
||||
| 'ambient_sound';
|
||||
|
||||
export type VisualNovelHistoryAssetKind =
|
||||
| 'character_visual'
|
||||
@@ -97,6 +98,11 @@ const VISUAL_NOVEL_UPLOAD_CONFIG = {
|
||||
assetKind: 'visual_novel_music',
|
||||
maxSizeBytes: 20 * 1024 * 1024,
|
||||
},
|
||||
ambient_sound: {
|
||||
legacyPrefix: 'generated-custom-world-scenes',
|
||||
assetKind: 'visual_novel_ambient_sound',
|
||||
maxSizeBytes: 20 * 1024 * 1024,
|
||||
},
|
||||
} satisfies Record<
|
||||
VisualNovelUploadAssetKind,
|
||||
{ legacyPrefix: string; assetKind: string; maxSizeBytes: number }
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
CreateVisualNovelBackgroundMusicRequest,
|
||||
CreateVisualNovelSoundEffectRequest,
|
||||
PublishVisualNovelGeneratedAudioAssetRequest,
|
||||
VisualNovelAudioGenerationTaskResponse,
|
||||
VisualNovelGeneratedAudioAssetResponse,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const VISUAL_NOVEL_AUDIO_API_BASE = '/api/creation/visual-novel/audio';
|
||||
|
||||
const VISUAL_NOVEL_AUDIO_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 1200,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export function createVisualNovelBackgroundMusicTask(
|
||||
payload: CreateVisualNovelBackgroundMusicRequest,
|
||||
) {
|
||||
return requestJson<VisualNovelAudioGenerationTaskResponse>(
|
||||
`${VISUAL_NOVEL_AUDIO_API_BASE}/background-music`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交背景音乐生成失败',
|
||||
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 20000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function publishVisualNovelBackgroundMusicAsset(
|
||||
taskId: string,
|
||||
payload: PublishVisualNovelGeneratedAudioAssetRequest,
|
||||
) {
|
||||
return requestJson<VisualNovelGeneratedAudioAssetResponse>(
|
||||
`${VISUAL_NOVEL_AUDIO_API_BASE}/background-music/${encodeURIComponent(taskId)}/asset`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成背景音乐素材失败',
|
||||
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 30000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createVisualNovelSoundEffectTask(
|
||||
payload: CreateVisualNovelSoundEffectRequest,
|
||||
) {
|
||||
return requestJson<VisualNovelAudioGenerationTaskResponse>(
|
||||
`${VISUAL_NOVEL_AUDIO_API_BASE}/sound-effect`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交音效生成失败',
|
||||
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 20000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function publishVisualNovelSoundEffectAsset(
|
||||
taskId: string,
|
||||
payload: PublishVisualNovelGeneratedAudioAssetRequest,
|
||||
) {
|
||||
return requestJson<VisualNovelGeneratedAudioAssetResponse>(
|
||||
`${VISUAL_NOVEL_AUDIO_API_BASE}/sound-effect/${encodeURIComponent(taskId)}/asset`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成音效素材失败',
|
||||
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 30000 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user