Checkpoint: v4.0 media service, compose deploy, and verified docs

这个提交包含在:
cryptocommuniums-afk
2026-03-14 21:45:31 +08:00
父节点 27083d5af9
当前提交 d5431aee0e
修改 41 个文件,包含 4056 行新增883 行删除

查看文件

@@ -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 普通文件
查看文件

@@ -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;
}
}