Checkpoint: v4.0 media service, compose deploy, and verified docs
这个提交包含在:
17
client/src/lib/media.test.ts
普通文件
17
client/src/lib/media.test.ts
普通文件
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRecordingTime, pickBitrate } from "./media";
|
||||
|
||||
describe("media utilities", () => {
|
||||
it("formats recording time with minute and second padding", () => {
|
||||
expect(formatRecordingTime(0)).toBe("00:00");
|
||||
expect(formatRecordingTime(61_000)).toBe("01:01");
|
||||
expect(formatRecordingTime(12 * 60_000 + 9_000)).toBe("12:09");
|
||||
});
|
||||
|
||||
it("selects bitrates by preset and device class", () => {
|
||||
expect(pickBitrate("economy", true)).toBe(1_000_000);
|
||||
expect(pickBitrate("clarity", false)).toBe(2_500_000);
|
||||
expect(pickBitrate("balanced", true)).toBe(1_400_000);
|
||||
expect(pickBitrate("balanced", false)).toBe(1_900_000);
|
||||
});
|
||||
});
|
||||
158
client/src/lib/media.ts
普通文件
158
client/src/lib/media.ts
普通文件
@@ -0,0 +1,158 @@
|
||||
export type MediaSessionStatus =
|
||||
| "created"
|
||||
| "recording"
|
||||
| "streaming"
|
||||
| "reconnecting"
|
||||
| "finalizing"
|
||||
| "archived"
|
||||
| "failed";
|
||||
|
||||
export type ArchiveStatus =
|
||||
| "idle"
|
||||
| "queued"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type MediaMarker = {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
timestampMs: number;
|
||||
confidence?: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type MediaSession = {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
status: MediaSessionStatus;
|
||||
archiveStatus: ArchiveStatus;
|
||||
format: string;
|
||||
mimeType: string;
|
||||
qualityPreset: string;
|
||||
facingMode: string;
|
||||
deviceKind: string;
|
||||
reconnectCount: number;
|
||||
uploadedSegments: number;
|
||||
uploadedBytes: number;
|
||||
durationMs: number;
|
||||
lastError?: string;
|
||||
streamConnected: boolean;
|
||||
lastStreamAt?: string;
|
||||
playback: {
|
||||
webmUrl?: string;
|
||||
mp4Url?: string;
|
||||
webmSize?: number;
|
||||
mp4Size?: number;
|
||||
ready: boolean;
|
||||
previewUrl?: string;
|
||||
};
|
||||
markers: MediaMarker[];
|
||||
};
|
||||
|
||||
const MEDIA_BASE = (import.meta.env.VITE_MEDIA_BASE_URL || "/media").replace(/\/$/, "");
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${MEDIA_BASE}${path}`, init);
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
throw new Error(errorBody.error || errorBody.message || `Media service error (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function createMediaSession(payload: {
|
||||
userId: string;
|
||||
title: string;
|
||||
format: string;
|
||||
mimeType: string;
|
||||
qualityPreset: string;
|
||||
facingMode: string;
|
||||
deviceKind: string;
|
||||
}) {
|
||||
return request<{ session: MediaSession }>("/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function signalMediaSession(sessionId: string, payload: { sdp: string; type: string }) {
|
||||
return request<{ sdp: string; type: string }>(`/sessions/${sessionId}/signal`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMediaSegment(
|
||||
sessionId: string,
|
||||
sequence: number,
|
||||
durationMs: number,
|
||||
blob: Blob
|
||||
) {
|
||||
return request<{ session: MediaSession }>(
|
||||
`/sessions/${sessionId}/segments?sequence=${sequence}&durationMs=${durationMs}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": blob.type || "video/webm" },
|
||||
body: blob,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function createMediaMarker(
|
||||
sessionId: string,
|
||||
payload: { type: string; label: string; timestampMs: number; confidence?: number }
|
||||
) {
|
||||
return request<{ session: MediaSession }>(`/sessions/${sessionId}/markers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function finalizeMediaSession(
|
||||
sessionId: string,
|
||||
payload: { title: string; durationMs: number }
|
||||
) {
|
||||
return request<{ session: MediaSession }>(`/sessions/${sessionId}/finalize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMediaSession(sessionId: string) {
|
||||
return request<{ session: MediaSession }>(`/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
export function formatRecordingTime(milliseconds: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function pickRecorderMimeType() {
|
||||
const candidates = [
|
||||
"video/webm;codecs=vp9,opus",
|
||||
"video/webm;codecs=vp8,opus",
|
||||
"video/webm;codecs=h264,opus",
|
||||
"video/webm",
|
||||
];
|
||||
return candidates.find((candidate) => window.MediaRecorder?.isTypeSupported(candidate)) || "video/webm";
|
||||
}
|
||||
|
||||
export function pickBitrate(preset: string, isMobile: boolean) {
|
||||
switch (preset) {
|
||||
case "economy":
|
||||
return 1_000_000;
|
||||
case "clarity":
|
||||
return 2_500_000;
|
||||
default:
|
||||
return isMobile ? 1_400_000 : 1_900_000;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户