refactor(tts,db,manifest): split tts_segments into normalized entry and variant tables

Redesign TTS segment storage by decomposing the `tts_segments` table into two normalized tables: `tts_segment_entries` for unique segment identity and locator projection, and `tts_segment_variants` for per-settings audio variants. Update schema, queries, and API routes to use the new structure, including manifest pagination and segment resolution logic. Refactor segment locator handling to use stable projections and manifest cursors. Migrate tests and data cleanup utilities to support the new model.

BREAKING CHANGE: Database schema for TTS segments is now split into entry and variant tables; all code and consumers must use the new structure. Persisted data and APIs relying on the old `tts_segments` table are incompatible.
This commit is contained in:
Richard R 2026-05-12 17:44:57 -06:00
parent f9adba791d
commit 8488ad37e1
21 changed files with 3853 additions and 298 deletions

View file

@ -0,0 +1,50 @@
CREATE TABLE "tts_segment_entries" (
"segment_entry_id" text NOT NULL,
"user_id" text NOT NULL,
"document_id" text NOT NULL,
"reader_type" text NOT NULL,
"document_version" bigint NOT NULL,
"segment_index" integer NOT NULL,
"segment_key" text,
"locator_reader_rank" integer NOT NULL,
"locator_reader_type" text NOT NULL,
"locator_page" integer NOT NULL,
"locator_spine_index" integer NOT NULL,
"locator_spine_href" text NOT NULL,
"locator_char_offset" integer NOT NULL,
"locator_location" text NOT NULL,
"locator_identity_key" text NOT NULL,
"text_hash" text NOT NULL,
"text_length" integer DEFAULT 0 NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "tts_segment_entries_segment_entry_id_user_id_pk" PRIMARY KEY("segment_entry_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "tts_segment_variants" (
"segment_id" text NOT NULL,
"user_id" text NOT NULL,
"segment_entry_id" text NOT NULL,
"settings_hash" text NOT NULL,
"settings_json" jsonb NOT NULL,
"audio_key" text,
"audio_format" text DEFAULT 'mp3' NOT NULL,
"duration_ms" integer,
"alignment_json" text,
"status" text DEFAULT 'pending' NOT NULL,
"error" text,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "tts_segment_variants_segment_id_user_id_pk" PRIMARY KEY("segment_id","user_id")
);
--> statement-breakpoint
DROP TABLE "tts_segments" CASCADE;--> statement-breakpoint
ALTER TABLE "tts_segment_entries" ADD CONSTRAINT "tts_segment_entries_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tts_segment_variants" ADD CONSTRAINT "tts_segment_variants_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tts_segment_variants" ADD CONSTRAINT "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk" FOREIGN KEY ("segment_entry_id","user_id") REFERENCES "public"."tts_segment_entries"("segment_entry_id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_tts_segment_entries_manifest_sort" ON "tts_segment_entries" USING btree ("user_id","document_id","document_version","locator_reader_rank","locator_spine_index","locator_char_offset","locator_spine_href","locator_page","locator_location","segment_index","locator_identity_key");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_entries_manifest_group" ON "tts_segment_entries" USING btree ("user_id","document_id","document_version","segment_index","locator_identity_key");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_entries_scope" ON "tts_segment_entries" USING btree ("user_id","document_id","document_version");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_variants_entry" ON "tts_segment_variants" USING btree ("user_id","segment_entry_id","updated_at");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_variants_status" ON "tts_segment_variants" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_variants_unique_settings" ON "tts_segment_variants" USING btree ("user_id","segment_entry_id","settings_hash");

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1778502597852,
"tag": "0002_add_segment_key_to_tts_segments",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778627816569,
"tag": "0003_tts_segments_v2_split",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,50 @@
CREATE TABLE `tts_segment_entries` (
`segment_entry_id` text NOT NULL,
`user_id` text NOT NULL,
`document_id` text NOT NULL,
`reader_type` text NOT NULL,
`document_version` integer NOT NULL,
`segment_index` integer NOT NULL,
`segment_key` text,
`locator_reader_rank` integer NOT NULL,
`locator_reader_type` text NOT NULL,
`locator_page` integer NOT NULL,
`locator_spine_index` integer NOT NULL,
`locator_spine_href` text NOT NULL,
`locator_char_offset` integer NOT NULL,
`locator_location` text NOT NULL,
`locator_identity_key` text NOT NULL,
`text_hash` text NOT NULL,
`text_length` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`segment_entry_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_tts_segment_entries_manifest_sort` ON `tts_segment_entries` (`user_id`,`document_id`,`document_version`,`locator_reader_rank`,`locator_spine_index`,`locator_char_offset`,`locator_spine_href`,`locator_page`,`locator_location`,`segment_index`,`locator_identity_key`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_entries_manifest_group` ON `tts_segment_entries` (`user_id`,`document_id`,`document_version`,`segment_index`,`locator_identity_key`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_entries_scope` ON `tts_segment_entries` (`user_id`,`document_id`,`document_version`);--> statement-breakpoint
CREATE TABLE `tts_segment_variants` (
`segment_id` text NOT NULL,
`user_id` text NOT NULL,
`segment_entry_id` text NOT NULL,
`settings_hash` text NOT NULL,
`settings_json` text NOT NULL,
`audio_key` text,
`audio_format` text DEFAULT 'mp3' NOT NULL,
`duration_ms` integer,
`alignment_json` text,
`status` text DEFAULT 'pending' NOT NULL,
`error` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`segment_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`segment_entry_id`,`user_id`) REFERENCES `tts_segment_entries`(`segment_entry_id`,`user_id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_tts_segment_variants_entry` ON `tts_segment_variants` (`user_id`,`segment_entry_id`,`updated_at`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_variants_status` ON `tts_segment_variants` (`user_id`,`status`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_variants_unique_settings` ON `tts_segment_variants` (`user_id`,`segment_entry_id`,`settings_hash`);--> statement-breakpoint
DROP TABLE `tts_segments`;

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1778502597591,
"tag": "0002_add_segment_key_to_tts_segments",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1778627810476,
"tag": "0003_tts_segments_v2_split",
"breakpoints": true
}
]
}

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { ttsSegments } from '@/db/schema';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
@ -27,22 +27,26 @@ export async function POST(request: NextRequest) {
const rows = (await db
.select({
segmentId: ttsSegments.segmentId,
audioKey: ttsSegments.audioKey,
segmentId: ttsSegmentVariants.segmentId,
audioKey: ttsSegmentVariants.audioKey,
})
.from(ttsSegments)
.from(ttsSegmentVariants)
.innerJoin(ttsSegmentEntries, and(
eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId),
eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId),
))
.where(and(
eq(ttsSegments.userId, scope.storageUserId),
eq(ttsSegments.documentId, parsed.documentId),
eq(ttsSegments.documentVersion, scope.documentVersion),
eq(ttsSegmentEntries.userId, scope.storageUserId),
eq(ttsSegmentEntries.documentId, parsed.documentId),
eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
))) as Array<{ segmentId: string; audioKey: string | null }>;
await db
.delete(ttsSegments)
.delete(ttsSegmentEntries)
.where(and(
eq(ttsSegments.userId, scope.storageUserId),
eq(ttsSegments.documentId, parsed.documentId),
eq(ttsSegments.documentVersion, scope.documentVersion),
eq(ttsSegmentEntries.userId, scope.storageUserId),
eq(ttsSegmentEntries.documentId, parsed.documentId),
eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
));
const audioKeys = rows

View file

@ -1,25 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray, ne } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { ttsSegments } from '@/db/schema';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { isS3Configured, getS3Config } from '@/lib/server/storage/s3';
import { generateTTSBuffer } from '@/lib/server/tts/generate';
import {
deleteTtsSegmentAudioObjects,
getTtsSegmentAudioObject,
putTtsSegmentAudioObject,
} from '@/lib/server/tts/segments-blobstore';
import {
buildTtsSegmentAudioKey,
buildTtsSegmentEntryId,
buildTtsSegmentId,
buildTtsSegmentSettingsHash,
buildTtsSegmentSettingsJson,
buildTtsSegmentTextHash,
canonicalLocatorJson,
canonicalizeLocatorJsonString,
locatorFingerprint,
normalizeLocator,
normalizeSegmentText,
projectSegmentLocator,
probeAudioDurationMsFromBuffer,
} from '@/lib/server/tts/segments';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
@ -140,67 +139,24 @@ function isAbortLikeError(error: unknown): boolean {
return /abort/i.test(message);
}
async function cleanupStaleCanonicalVariants(input: {
userId: string;
documentId: string;
documentVersion: number;
segmentIndex: number;
activeLocatorJson: string | null;
activeSegmentId: string;
activeSettingsHash: string;
}): Promise<void> {
const staleRows = (await db
.select({
segmentId: ttsSegments.segmentId,
settingsHash: ttsSegments.settingsHash,
audioKey: ttsSegments.audioKey,
locatorJson: ttsSegments.locatorJson,
})
.from(ttsSegments)
async function deleteEntryIfUnused(userId: string, segmentEntryId: string): Promise<void> {
const stillReferenced = await db
.select({ segmentId: ttsSegmentVariants.segmentId })
.from(ttsSegmentVariants)
.where(and(
eq(ttsSegments.userId, input.userId),
eq(ttsSegments.documentId, input.documentId),
eq(ttsSegments.documentVersion, input.documentVersion),
eq(ttsSegments.segmentIndex, input.segmentIndex),
ne(ttsSegments.segmentId, input.activeSegmentId),
))) as Array<{
segmentId: string;
settingsHash: string;
audioKey: string | null;
locatorJson: string | null;
}>;
eq(ttsSegmentVariants.userId, userId),
eq(ttsSegmentVariants.segmentEntryId, segmentEntryId),
))
.limit(1);
const activeCanonicalLocator = canonicalizeLocatorJsonString(input.activeLocatorJson);
const staleRowsForSettings = staleRows.filter((row) =>
row.settingsHash === input.activeSettingsHash
&& canonicalizeLocatorJsonString(row.locatorJson) === activeCanonicalLocator,
);
const staleIds = staleRowsForSettings.map((row) => row.segmentId);
if (staleIds.length === 0) return;
if (stillReferenced.length > 0) return;
await db
.delete(ttsSegments)
.delete(ttsSegmentEntries)
.where(and(
eq(ttsSegments.userId, input.userId),
inArray(ttsSegments.segmentId, staleIds),
eq(ttsSegmentEntries.userId, userId),
eq(ttsSegmentEntries.segmentEntryId, segmentEntryId),
));
const staleAudioKeys = Array.from(new Set(staleRowsForSettings
.map((row) => row.audioKey)
.filter((key): key is string => Boolean(key))));
if (staleAudioKeys.length > 0) {
try {
await deleteTtsSegmentAudioObjects(staleAudioKeys);
} catch (error) {
console.warn('Failed deleting stale TTS segment audio objects:', {
documentId: input.documentId,
userId: input.userId,
segmentIndex: input.segmentIndex,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
export async function POST(request: NextRequest) {
@ -223,11 +179,16 @@ export async function POST(request: NextRequest) {
const storagePrefix = getS3Config().prefix;
const secret = textHmacSecret();
let invalidLocatorIndex = -1;
const normalized = parsed.segments
.map((segment) => {
.map((segment, index) => {
const text = normalizeSegmentText(segment.text);
if (!text) return null;
const locator = normalizeLocator(segment.locator);
if (!locator) {
invalidLocatorIndex = index;
return null;
}
const locatorHash = locatorFingerprint(locator);
const segmentId = buildTtsSegmentId({
documentId: parsed.documentId,
@ -249,6 +210,13 @@ export async function POST(request: NextRequest) {
})
.filter((value): value is NonNullable<typeof value> => Boolean(value));
if (invalidLocatorIndex >= 0) {
return NextResponse.json(
{ error: `Invalid or unsupported segment locator at index ${invalidLocatorIndex}` },
{ status: 400 },
);
}
if (normalized.length === 0) {
return NextResponse.json({ error: 'No valid non-empty segments provided' }, { status: 400 });
}
@ -256,19 +224,16 @@ export async function POST(request: NextRequest) {
const ids = normalized.map((segment) => segment.segmentId);
const rows = (await db
.select()
.from(ttsSegments)
.where(and(eq(ttsSegments.userId, scope.storageUserId), inArray(ttsSegments.segmentId, ids)))) as Array<{
.from(ttsSegmentVariants)
.where(and(
eq(ttsSegmentVariants.userId, scope.storageUserId),
inArray(ttsSegmentVariants.segmentId, ids),
))) as Array<{
segmentId: string;
userId: string;
documentId: string;
readerType: string;
documentVersion: number;
segmentIndex: number;
locatorJson: string | null;
segmentEntryId: string;
settingsHash: string;
settingsJson: unknown;
textHash: string;
textLength: number;
audioKey: string | null;
audioFormat: string;
durationMs: number | null;
@ -283,25 +248,74 @@ export async function POST(request: NextRequest) {
const manifest: TTSSegmentManifestItem[] = [];
for (const segment of normalized) {
const existing = existingById.get(segment.segmentId);
const locatorProjection = projectSegmentLocator(segment.locator);
const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim()
? segment.original.segmentKey.trim()
: null;
const segmentEntryId = buildTtsSegmentEntryId({
documentId: parsed.documentId,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorIdentityKey: locatorProjection.locatorIdentityKey,
textHash: segment.textHash,
});
if (existing?.status === 'completed' && existing.audioKey) {
await cleanupStaleCanonicalVariants({
await db
.insert(ttsSegmentEntries)
.values({
segmentEntryId,
userId: scope.storageUserId,
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
activeLocatorJson: existing.locatorJson,
activeSegmentId: segment.segmentId,
activeSettingsHash: settingsHash,
segmentKey: segmentKeyForRow,
...locatorProjection,
textHash: segment.textHash,
textLength: segment.text.length,
updatedAt: nowMs,
})
.onConflictDoUpdate({
target: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
set: {
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
...locatorProjection,
textHash: segment.textHash,
textLength: segment.text.length,
updatedAt: nowMs,
},
});
const existing = existingById.get(segment.segmentId);
const movedFromEntryId = existing && existing.segmentEntryId !== segmentEntryId
? existing.segmentEntryId
: null;
if (existing?.status === 'completed' && existing.audioKey) {
if (movedFromEntryId) {
await db
.update(ttsSegmentVariants)
.set({
segmentEntryId,
settingsJson,
updatedAt: nowMs,
})
.where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
));
await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId);
}
let alignment = existing.alignmentJson
? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment'])
: null;
const locator = existing.locatorJson
? (JSON.parse(existing.locatorJson) as TTSSegmentManifestItem['locator'])
: null;
const locator = segment.locator;
// Self-heal transient Whisper failures: if audio exists but alignment was
// previously unavailable, retry alignment using the current segment text.
@ -319,12 +333,15 @@ export async function POST(request: NextRequest) {
if (alignment) {
await db
.update(ttsSegments)
.update(ttsSegmentVariants)
.set({
alignmentJson: JSON.stringify(alignment),
updatedAt: Date.now(),
})
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
.where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
));
}
} catch (alignError) {
console.warn('Whisper alignment still unavailable for completed segment; continuing without word highlights.', {
@ -337,8 +354,8 @@ export async function POST(request: NextRequest) {
manifest.push({
segmentId: segment.segmentId,
segmentIndex: existing.segmentIndex,
segmentKey: segment.original.segmentKey ?? null,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
...buildSegmentAudioUrls(parsed.documentId, segment.segmentId),
durationMs: existing.durationMs ?? 0,
alignment,
@ -358,25 +375,14 @@ export async function POST(request: NextRequest) {
segmentId: segment.segmentId,
});
const segmentLocatorJson = canonicalLocatorJson(segment.locator);
const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim()
? segment.original.segmentKey.trim()
: null;
await db
.insert(ttsSegments)
.insert(ttsSegmentVariants)
.values({
segmentId: segment.segmentId,
userId: scope.storageUserId,
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorJson: segmentLocatorJson,
segmentEntryId,
settingsHash,
settingsJson,
textHash: segment.textHash,
textLength: segment.text.length,
audioKey,
audioFormat: 'mp3',
status: 'pending',
@ -384,18 +390,11 @@ export async function POST(request: NextRequest) {
updatedAt: nowMs,
})
.onConflictDoUpdate({
target: [ttsSegments.segmentId, ttsSegments.userId],
target: [ttsSegmentVariants.segmentId, ttsSegmentVariants.userId],
set: {
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorJson: segmentLocatorJson,
segmentEntryId,
settingsHash,
settingsJson,
textHash: segment.textHash,
textLength: segment.text.length,
audioKey,
audioFormat: 'mp3',
status: 'pending',
@ -404,6 +403,10 @@ export async function POST(request: NextRequest) {
},
});
if (movedFromEntryId) {
await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId);
}
try {
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
const charCount = segment.text.length;
@ -493,7 +496,7 @@ export async function POST(request: NextRequest) {
}
await db
.update(ttsSegments)
.update(ttsSegmentVariants)
.set({
durationMs,
alignmentJson: alignment ? JSON.stringify(alignment) : null,
@ -501,22 +504,15 @@ export async function POST(request: NextRequest) {
error: null,
updatedAt: Date.now(),
})
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
await cleanupStaleCanonicalVariants({
userId: scope.storageUserId,
documentId: parsed.documentId,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
activeLocatorJson: canonicalLocatorJson(segment.locator),
activeSegmentId: segment.segmentId,
activeSettingsHash: settingsHash,
});
.where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
));
manifest.push({
segmentId: segment.segmentId,
segmentIndex: segment.original.segmentIndex,
segmentKey: segment.original.segmentKey ?? null,
segmentKey: segmentKeyForRow,
...buildSegmentAudioUrls(parsed.documentId, segment.segmentId),
durationMs,
alignment,
@ -527,18 +523,21 @@ export async function POST(request: NextRequest) {
const message = error instanceof Error ? error.message : 'Failed to generate segment';
const aborted = isAbortLikeError(error);
await db
.update(ttsSegments)
.update(ttsSegmentVariants)
.set({
status: aborted ? 'pending' : 'error',
error: aborted ? null : message,
updatedAt: Date.now(),
})
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
.where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
));
manifest.push({
segmentId: segment.segmentId,
segmentIndex: segment.original.segmentIndex,
segmentKey: segment.original.segmentKey ?? null,
segmentKey: segmentKeyForRow,
audioPresignUrl: null,
audioFallbackUrl: null,
durationMs: 0,

View file

@ -1,15 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, asc, eq } from 'drizzle-orm';
import { and, asc, eq, gt, inArray, or } from 'drizzle-orm';
import { db } from '@/db';
import { ttsSegments } from '@/db/schema';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import {
compareManifestSegments,
decodeManifestCursor,
dedupeManifestVariants,
encodeManifestCursor,
locatorIdentityKey,
parseManifestPageSize,
type TTSSegmentManifestCursor,
} from '@/lib/server/tts/segments-manifest';
import type {
TTSSegmentLocator,
@ -22,6 +21,21 @@ import type {
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type ManifestGroupRow = {
segmentEntryId: string;
segmentIndex: number;
segmentKey: string | null;
textLength: number;
locatorReaderRank: number;
locatorReaderType: string;
locatorPage: number;
locatorSpineIndex: number;
locatorSpineHref: string;
locatorCharOffset: number;
locatorLocation: string;
locatorIdentityKey: string;
};
function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
let raw: unknown = value;
if (typeof raw === 'string') {
@ -30,9 +44,6 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
if (!raw || typeof raw !== 'object') return null;
const rec = raw as Record<string, unknown>;
// Settings stored via buildTtsSegmentSettingsJson — accept either the runtime
// shape (ttsProvider/ttsModel/voice/nativeSpeed/ttsInstructions) or the
// canonical hash form (provider/model/voice/speed/instructions/format).
const ttsProvider = typeof rec.ttsProvider === 'string'
? rec.ttsProvider
: typeof rec.provider === 'string' ? rec.provider : null;
@ -49,16 +60,22 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions };
}
function parseLocator(value: unknown): TTSSegmentLocator | null {
if (!value) return null;
if (typeof value !== 'string') return value as TTSSegmentLocator;
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object') return null;
return parsed as TTSSegmentLocator;
} catch {
return null;
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
if (row.locatorReaderType === 'epub' && row.locatorSpineIndex >= 0 && row.locatorCharOffset >= 0 && row.locatorSpineHref) {
return {
readerType: 'epub',
spineIndex: row.locatorSpineIndex,
spineHref: row.locatorSpineHref,
charOffset: row.locatorCharOffset,
};
}
if (row.locatorReaderType === 'pdf' && row.locatorPage >= 1) {
return { readerType: 'pdf', page: row.locatorPage };
}
if (row.locatorReaderType === 'html' && row.locatorLocation) {
return { readerType: 'html', location: row.locatorLocation };
}
return null;
}
function buildSegmentAudioUrls(documentId: string, segmentId: string): {
@ -78,6 +95,86 @@ function isAbortLikeMessage(message: string | null | undefined): boolean {
return /abort/i.test(message);
}
function buildKeysetWhere(cursor: TTSSegmentManifestCursor) {
return or(
gt(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
gt(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
gt(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
gt(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
gt(ttsSegmentEntries.locatorPage, cursor.locatorPage),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
gt(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
gt(ttsSegmentEntries.segmentIndex, cursor.segmentIndex),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
eq(ttsSegmentEntries.segmentIndex, cursor.segmentIndex),
gt(ttsSegmentEntries.locatorIdentityKey, cursor.locatorIdentityKey),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
eq(ttsSegmentEntries.segmentIndex, cursor.segmentIndex),
eq(ttsSegmentEntries.locatorIdentityKey, cursor.locatorIdentityKey),
gt(ttsSegmentEntries.segmentEntryId, cursor.segmentEntryId),
),
);
}
function cursorFromGroupRow(row: ManifestGroupRow): TTSSegmentManifestCursor {
return {
locatorReaderRank: row.locatorReaderRank,
locatorSpineIndex: row.locatorSpineIndex,
locatorCharOffset: row.locatorCharOffset,
locatorSpineHref: row.locatorSpineHref,
locatorPage: row.locatorPage,
locatorLocation: row.locatorLocation,
segmentIndex: row.segmentIndex,
locatorIdentityKey: row.locatorIdentityKey,
segmentEntryId: row.segmentEntryId,
};
}
export async function GET(request: NextRequest) {
try {
const documentIdRaw = request.nextUrl.searchParams.get('documentId');
@ -91,27 +188,92 @@ export async function GET(request: NextRequest) {
const scope = await resolveSegmentDocumentScope(request, documentId);
if (scope instanceof Response) return scope;
const rows = (await db
.select()
.from(ttsSegments)
.where(and(
eq(ttsSegments.userId, scope.storageUserId),
eq(ttsSegments.documentId, documentId),
eq(ttsSegments.documentVersion, scope.documentVersion),
const scopeWhere = and(
eq(ttsSegmentEntries.userId, scope.storageUserId),
eq(ttsSegmentEntries.documentId, documentId),
eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
);
const groupWhere = cursor
? and(scopeWhere, buildKeysetWhere(cursor))
: scopeWhere;
const groupedRows = (await db
.select({
segmentEntryId: ttsSegmentEntries.segmentEntryId,
segmentIndex: ttsSegmentEntries.segmentIndex,
segmentKey: ttsSegmentEntries.segmentKey,
textLength: ttsSegmentEntries.textLength,
locatorReaderRank: ttsSegmentEntries.locatorReaderRank,
locatorReaderType: ttsSegmentEntries.locatorReaderType,
locatorPage: ttsSegmentEntries.locatorPage,
locatorSpineIndex: ttsSegmentEntries.locatorSpineIndex,
locatorSpineHref: ttsSegmentEntries.locatorSpineHref,
locatorCharOffset: ttsSegmentEntries.locatorCharOffset,
locatorLocation: ttsSegmentEntries.locatorLocation,
locatorIdentityKey: ttsSegmentEntries.locatorIdentityKey,
})
.from(ttsSegmentEntries)
.innerJoin(ttsSegmentVariants, and(
eq(ttsSegmentVariants.segmentEntryId, ttsSegmentEntries.segmentEntryId),
eq(ttsSegmentVariants.userId, ttsSegmentEntries.userId),
))
.orderBy(asc(ttsSegments.segmentIndex), asc(ttsSegments.updatedAt))) as Array<{
.where(groupWhere)
.groupBy(
ttsSegmentEntries.segmentEntryId,
ttsSegmentEntries.segmentIndex,
ttsSegmentEntries.segmentKey,
ttsSegmentEntries.textLength,
ttsSegmentEntries.locatorReaderRank,
ttsSegmentEntries.locatorReaderType,
ttsSegmentEntries.locatorPage,
ttsSegmentEntries.locatorSpineIndex,
ttsSegmentEntries.locatorSpineHref,
ttsSegmentEntries.locatorCharOffset,
ttsSegmentEntries.locatorLocation,
ttsSegmentEntries.locatorIdentityKey,
)
.orderBy(
asc(ttsSegmentEntries.locatorReaderRank),
asc(ttsSegmentEntries.locatorSpineIndex),
asc(ttsSegmentEntries.locatorCharOffset),
asc(ttsSegmentEntries.locatorSpineHref),
asc(ttsSegmentEntries.locatorPage),
asc(ttsSegmentEntries.locatorLocation),
asc(ttsSegmentEntries.segmentIndex),
asc(ttsSegmentEntries.locatorIdentityKey),
asc(ttsSegmentEntries.segmentEntryId),
)
.limit(limit + 1)) as ManifestGroupRow[];
const hasMore = groupedRows.length > limit;
const pageGroups = hasMore ? groupedRows.slice(0, limit) : groupedRows;
if (pageGroups.length === 0) {
const emptyResponse: TTSSegmentsManifestResponse = {
documentId,
segments: [],
nextCursor: null,
hasMore: false,
};
return NextResponse.json(emptyResponse);
}
const entryIds = pageGroups.map((row) => row.segmentEntryId);
const entryById = new Map(pageGroups.map((entry) => [entry.segmentEntryId, entry]));
const variantRows = (await db
.select()
.from(ttsSegmentVariants)
.where(and(
eq(ttsSegmentVariants.userId, scope.storageUserId),
inArray(ttsSegmentVariants.segmentEntryId, entryIds),
))
.orderBy(asc(ttsSegmentVariants.segmentEntryId), asc(ttsSegmentVariants.updatedAt))) as Array<{
segmentId: string;
userId: string;
documentId: string;
readerType: string;
documentVersion: number;
segmentIndex: number;
segmentKey: string | null;
locatorJson: string | null;
segmentEntryId: string;
settingsHash: string;
settingsJson: unknown;
textHash: string;
textLength: number;
audioKey: string | null;
audioFormat: string;
durationMs: number | null;
@ -126,26 +288,21 @@ export async function GET(request: NextRequest) {
variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>;
}>();
for (const row of rows) {
const locator = parseLocator(row.locatorJson);
// Use the per-row identity key (not the coarse sidebar group key) so two
// persisted rows in the same chapter at different `charOffset`s remain
// distinct entries instead of collapsing into one bucket whose locator
// only reflects the first row seen.
const groupKey = `${row.segmentIndex}|${locatorIdentityKey(locator)}`;
for (const row of variantRows) {
const entryMeta = entryById.get(row.segmentEntryId);
if (!entryMeta) continue;
const key = row.segmentEntryId;
const locator = locatorFromProjection(entryMeta);
let entry = grouped.get(groupKey);
let entry = grouped.get(key);
if (!entry) {
entry = {
segmentIndex: row.segmentIndex,
segmentKey: row.segmentKey,
segmentIndex: entryMeta.segmentIndex,
segmentKey: entryMeta.segmentKey,
locator,
variants: [],
};
grouped.set(groupKey, entry);
} else {
if (!entry.locator) entry.locator = locator;
if (!entry.segmentKey && row.segmentKey) entry.segmentKey = row.segmentKey;
grouped.set(key, entry);
}
let alignmentWordCount = 0;
@ -177,7 +334,7 @@ export async function GET(request: NextRequest) {
audioFallbackUrl: audioUrls.audioFallbackUrl,
durationMs: row.durationMs,
status,
textLength: row.textLength,
textLength: entryMeta.textLength,
alignmentWordCount,
audioKey: row.audioKey,
updatedAt: row.updatedAt,
@ -185,39 +342,25 @@ export async function GET(request: NextRequest) {
});
}
const segments = Array.from(grouped.entries())
.map(([groupKey, segment]) => ({
groupKey,
segmentIndex: segment.segmentIndex,
segmentKey: segment.segmentKey,
locator: segment.locator,
variants: dedupeManifestVariants(segment.variants),
}))
.sort(compareManifestSegments);
const segments = pageGroups
.map((row) => {
const key = row.segmentEntryId;
const entry = grouped.get(key);
return {
segmentIndex: row.segmentIndex,
segmentKey: row.segmentKey ?? null,
locator: entry?.locator || locatorFromProjection(row),
variants: dedupeManifestVariants(entry?.variants || []),
};
});
let startIndex = 0;
if (cursor) {
const cursorIndex = segments.findIndex((segment) => segment.groupKey === cursor);
if (cursorIndex < 0) {
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 });
}
if (cursorIndex >= 0) startIndex = cursorIndex + 1;
}
const page = segments.slice(startIndex, startIndex + limit);
const hasMore = startIndex + page.length < segments.length;
const nextCursor = hasMore && page.length > 0
? encodeManifestCursor(page[page.length - 1].groupKey)
const nextCursor = hasMore
? encodeManifestCursor(cursorFromGroupRow(pageGroups[pageGroups.length - 1]))
: null;
const response: TTSSegmentsManifestResponse = {
documentId,
segments: page.map((segment) => ({
segmentIndex: segment.segmentIndex,
segmentKey: segment.segmentKey ?? null,
locator: segment.locator,
variants: segment.variants,
})),
segments,
nextCursor,
hasMore,
};

View file

@ -44,7 +44,6 @@ type FetchState =
| {
kind: 'ready';
data: TTSSegmentRow[];
fetchedAt: number;
nextCursor: string | null;
hasMore: boolean;
loadingMore: boolean;
@ -290,7 +289,6 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
return {
kind: 'ready',
data: merged,
fetchedAt: Date.now(),
nextCursor: data.nextCursor,
hasMore: data.hasMore,
loadingMore: false,

View file

@ -177,6 +177,11 @@ type EpubLocationPreloadCandidate = {
cacheKey: string;
};
type PersistResolution = {
segments: TTSSegmentInput[];
sourceIndices: number[];
};
type JumpResolutionInput = {
isEPUB: boolean;
newSentenceCount: number;
@ -313,6 +318,9 @@ const locatorForLocation = (
if (typeof location === 'string') {
return { location, readerType };
}
if (readerType === 'html') {
return { location: String(location || 1), readerType };
}
return { page: Math.max(1, Math.floor(Number(location || 1))), readerType };
};
@ -425,13 +433,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
*/
const resolveSegmentsForPersist = useCallback(async (
segments: TTSSegmentInput[],
): Promise<TTSSegmentInput[]> => {
): Promise<PersistResolution> => {
const resolver = epubLocatorResolverRef.current;
const out: TTSSegmentInput[] = [];
for (const segment of segments) {
const sourceIndices: number[] = [];
for (let idx = 0; idx < segments.length; idx += 1) {
const segment = segments[idx];
const locator = segment.locator;
if (!locator || locator.readerType !== 'epub') {
out.push(segment);
sourceIndices.push(idx);
continue;
}
if (!resolver) {
@ -461,12 +472,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
? { text: resolved.text }
: {}),
});
sourceIndices.push(idx);
}
} catch (error) {
console.warn('Failed to resolve EPUB locator; dropping segment', error);
}
}
return out;
return {
segments: out,
sourceIndices,
};
}, [documentId, ttsSegmentMaxBlockLength]);
/**
@ -1376,9 +1391,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
wordHighlightFeatureEnabled &&
((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled));
const locator = locatorOverride || (isEPUB
? { location: String(currDocPage), readerType: currentReaderType }
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType });
const locator = locatorOverride || locatorForLocation(
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
currentReaderType,
);
const audioCacheKey = buildScopedSegmentCacheKey(
locator,
sentenceIndex,
@ -1432,7 +1448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
try {
onTTSStart();
const persistSegments = await resolveSegmentsForPersist([
const persistResult = await resolveSegmentsForPersist([
{
segmentIndex: sentenceIndex,
...(segmentKey ? { segmentKey } : {}),
@ -1440,6 +1456,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
locator,
},
]);
const persistSegments = persistResult.segments;
if (persistSegments.length === 0) {
if (!preload) setIsPlaying(false);
return undefined;
@ -1566,9 +1583,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
segmentKey?: string | null,
): Promise<TTSSegmentPlaybackSource | null> => {
if (!audioContext) throw new Error('Audio context not initialized');
const locator = locatorOverride || (isEPUB
? { location: String(currDocPage), readerType: currentReaderType }
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType });
const locator = locatorOverride || locatorForLocation(
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
currentReaderType,
);
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey);
// Check if there's a pending preload request for this sentence
@ -1988,9 +2006,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
completedEpubBoundarySegmentRef.current = null;
}
const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator
?? (isEPUB
? { location: String(currDocPage), readerType: currentReaderType }
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType });
?? locatorForLocation(
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
currentReaderType,
);
const alignmentKey = buildScopedSegmentCacheKey(
activeLocator,
currentIndex,
@ -2284,7 +2303,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const preloadPromise = (async (): Promise<void> => {
onTTSStart();
started = true;
const persistPayload = await resolveSegmentsForPersist(payload);
const persistResult = await resolveSegmentsForPersist(payload);
const persistPayload = persistResult.segments;
if (persistPayload.length === 0) return;
const ensured = await withRetry(
async () => ensureTtsSegments({
@ -2303,18 +2323,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (generationAtStart !== epubPreloadGenerationRef.current) return;
const segmentLookup = new Map<string, TTSSegmentManifestItem>();
for (const segment of ensured.segments) {
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) continue;
if (!segment.locator) continue;
segmentLookup.set(segment.segmentKey || `${buildLocatorRequestKey(segment.locator)}::${segment.segmentIndex}`, segment);
}
for (const candidate of uniqueCandidates) {
const segment = segmentLookup.get(candidate.segmentKey);
if (!segment) continue;
ensured.segments.forEach((segment, persistIndex) => {
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return;
const sourceIndex = persistResult.sourceIndices[persistIndex];
if (sourceIndex === undefined) return;
const candidate = uniqueCandidates[sourceIndex];
if (!candidate) return;
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
}
});
})();
for (const candidate of uniqueCandidates) {
@ -2373,9 +2389,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
cacheKey: string;
}> = [];
const currentLocator: TTSSegmentLocator = isEPUB
? { location: String(currDocPage), readerType: currentReaderType }
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType };
const currentLocator: TTSSegmentLocator = locatorForLocation(
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
currentReaderType,
);
for (let offset = 1; offset <= sentenceLookahead; offset += 1) {
const sentenceIndex = currentIndex + offset;
@ -2407,9 +2424,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
: null;
if (location === null) continue;
const locator: TTSSegmentLocator = typeof location === 'string'
? { location, readerType: currentReaderType }
: { page: Number(location || 1), readerType: currentReaderType };
const locator: TTSSegmentLocator = locatorForLocation(location, currentReaderType);
for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) {
const segment = planned[index];
const sentence = segment.text;
@ -2471,7 +2486,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const preloadPromise = (async (): Promise<void> => {
try {
onTTSStart();
const persistPayload = await resolveSegmentsForPersist(payload);
const persistResult = await resolveSegmentsForPersist(payload);
const persistPayload = persistResult.segments;
if (persistPayload.length === 0) return;
const ensured = await withRetry(
async () => ensureTtsSegments({
@ -2488,10 +2504,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
retryOptions,
);
ensured.segments.forEach((segment) => {
const candidate = candidateLookup.get(
candidateLookupKey(segment.segmentIndex, segment.segmentKey),
);
ensured.segments.forEach((segment, persistIndex) => {
const sourceIndex = persistResult.sourceIndices[persistIndex];
if (sourceIndex === undefined) return;
const candidate = uniqueCandidates[sourceIndex]
?? candidateLookup.get(candidateLookupKey(segment.segmentIndex, segment.segmentKey));
if (!candidate) return;
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
});

View file

@ -13,4 +13,5 @@ export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSc
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;
export const ttsSegments = usePostgres ? postgresSchema.ttsSegments : sqliteSchema.ttsSegments;
export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries : sqliteSchema.ttsSegmentEntries;
export const ttsSegmentVariants = usePostgres ? postgresSchema.ttsSegmentVariants : sqliteSchema.ttsSegmentVariants;

View file

@ -109,19 +109,61 @@ export const documentPreviews = pgTable('document_previews', {
index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs),
]);
export const ttsSegments = pgTable('tts_segments', {
segmentId: text('segment_id').notNull(),
export const ttsSegmentEntries = pgTable('tts_segment_entries', {
segmentEntryId: text('segment_entry_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(),
documentVersion: bigint('document_version', { mode: 'number' }).notNull(),
segmentIndex: integer('segment_index').notNull(),
segmentKey: text('segment_key'),
locatorJson: text('locator_json'),
settingsHash: text('settings_hash').notNull(),
settingsJson: jsonb('settings_json').notNull(),
locatorReaderRank: integer('locator_reader_rank').notNull(),
locatorReaderType: text('locator_reader_type').notNull(),
locatorPage: integer('locator_page').notNull(),
locatorSpineIndex: integer('locator_spine_index').notNull(),
locatorSpineHref: text('locator_spine_href').notNull(),
locatorCharOffset: integer('locator_char_offset').notNull(),
locatorLocation: text('locator_location').notNull(),
locatorIdentityKey: text('locator_identity_key').notNull(),
textHash: text('text_hash').notNull(),
textLength: integer('text_length').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentEntryId, table.userId] }),
index('idx_tts_segment_entries_manifest_sort').on(
table.userId,
table.documentId,
table.documentVersion,
table.locatorReaderRank,
table.locatorSpineIndex,
table.locatorCharOffset,
table.locatorSpineHref,
table.locatorPage,
table.locatorLocation,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_manifest_group').on(
table.userId,
table.documentId,
table.documentVersion,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_scope').on(
table.userId,
table.documentId,
table.documentVersion,
),
]);
export const ttsSegmentVariants = pgTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
segmentEntryId: text('segment_entry_id').notNull(),
settingsHash: text('settings_hash').notNull(),
settingsJson: jsonb('settings_json').notNull(),
audioKey: text('audio_key'),
audioFormat: text('audio_format').notNull().default('mp3'),
durationMs: integer('duration_ms'),
@ -132,6 +174,11 @@ export const ttsSegments = pgTable('tts_segments', {
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentId, table.userId] }),
index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash),
index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex),
foreignKey({
columns: [table.segmentEntryId, table.userId],
foreignColumns: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
}).onDelete('cascade'),
index('idx_tts_segment_variants_entry').on(table.userId, table.segmentEntryId, table.updatedAt),
index('idx_tts_segment_variants_status').on(table.userId, table.status),
index('idx_tts_segment_variants_unique_settings').on(table.userId, table.segmentEntryId, table.settingsHash),
]);

View file

@ -109,19 +109,61 @@ export const documentPreviews = sqliteTable('document_previews', {
index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs),
]);
export const ttsSegments = sqliteTable('tts_segments', {
segmentId: text('segment_id').notNull(),
export const ttsSegmentEntries = sqliteTable('tts_segment_entries', {
segmentEntryId: text('segment_entry_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(),
documentVersion: integer('document_version').notNull(),
segmentIndex: integer('segment_index').notNull(),
segmentKey: text('segment_key'),
locatorJson: text('locator_json'),
settingsHash: text('settings_hash').notNull(),
settingsJson: text('settings_json').notNull(),
locatorReaderRank: integer('locator_reader_rank').notNull(),
locatorReaderType: text('locator_reader_type').notNull(),
locatorPage: integer('locator_page').notNull(),
locatorSpineIndex: integer('locator_spine_index').notNull(),
locatorSpineHref: text('locator_spine_href').notNull(),
locatorCharOffset: integer('locator_char_offset').notNull(),
locatorLocation: text('locator_location').notNull(),
locatorIdentityKey: text('locator_identity_key').notNull(),
textHash: text('text_hash').notNull(),
textLength: integer('text_length').notNull().default(0),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentEntryId, table.userId] }),
index('idx_tts_segment_entries_manifest_sort').on(
table.userId,
table.documentId,
table.documentVersion,
table.locatorReaderRank,
table.locatorSpineIndex,
table.locatorCharOffset,
table.locatorSpineHref,
table.locatorPage,
table.locatorLocation,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_manifest_group').on(
table.userId,
table.documentId,
table.documentVersion,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_scope').on(
table.userId,
table.documentId,
table.documentVersion,
),
]);
export const ttsSegmentVariants = sqliteTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
segmentEntryId: text('segment_entry_id').notNull(),
settingsHash: text('settings_hash').notNull(),
settingsJson: text('settings_json').notNull(),
audioKey: text('audio_key'),
audioFormat: text('audio_format').notNull().default('mp3'),
durationMs: integer('duration_ms'),
@ -132,6 +174,11 @@ export const ttsSegments = sqliteTable('tts_segments', {
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentId, table.userId] }),
index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash),
index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex),
foreignKey({
columns: [table.segmentEntryId, table.userId],
foreignColumns: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
}).onDelete('cascade'),
index('idx_tts_segment_variants_entry').on(table.userId, table.segmentEntryId, table.updatedAt),
index('idx_tts_segment_variants_status').on(table.userId, table.status),
index('idx_tts_segment_variants_unique_settings').on(table.userId, table.segmentEntryId, table.settingsHash),
]);

View file

@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { ttsSegments } from '@/db/schema';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
export type ResolvedSegmentAudio = {
@ -41,15 +41,19 @@ export async function resolveCompletedSegmentAudio(
const rows = (await db
.select({
audioKey: ttsSegments.audioKey,
status: ttsSegments.status,
audioKey: ttsSegmentVariants.audioKey,
status: ttsSegmentVariants.status,
})
.from(ttsSegments)
.from(ttsSegmentVariants)
.innerJoin(ttsSegmentEntries, and(
eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId),
eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId),
))
.where(and(
eq(ttsSegments.userId, scope.storageUserId),
eq(ttsSegments.documentId, documentId),
eq(ttsSegments.documentVersion, scope.documentVersion),
eq(ttsSegments.segmentId, segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
eq(ttsSegmentEntries.documentId, documentId),
eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
eq(ttsSegmentVariants.segmentId, segmentId),
))) as Array<{ audioKey: string | null; status: string }>;
const row = rows[0];

View file

@ -10,6 +10,18 @@ export const DEFAULT_PAGE_SIZE = 150;
export const MIN_PAGE_SIZE = 25;
export const MAX_PAGE_SIZE = 500;
export type TTSSegmentManifestCursor = {
locatorReaderRank: number;
locatorSpineIndex: number;
locatorCharOffset: number;
locatorSpineHref: string;
locatorPage: number;
locatorLocation: string;
segmentIndex: number;
locatorIdentityKey: string;
segmentEntryId: string;
};
function statusRank(status: TTSSegmentVariant['status']): number {
if (status === 'completed') return 3;
if (status === 'pending') return 2;
@ -45,24 +57,49 @@ export function compareManifestSegments(
return a.groupKey.localeCompare(b.groupKey);
}
export function decodeManifestCursor(cursor: string | null): string | null {
export function decodeManifestCursor(cursor: string | null): TTSSegmentManifestCursor | null {
if (!cursor) return null;
try {
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
if (!decoded) return null;
// Reject malformed input that Node's base64url decoder may parse into gibberish.
const normalizedInput = cursor.replace(/=+$/, '');
if (encodeManifestCursor(decoded) !== normalizedInput) {
let parsed: unknown = null;
try {
parsed = JSON.parse(decoded);
} catch {
return null;
}
return decoded;
if (!parsed || typeof parsed !== 'object') return null;
const rec = parsed as Record<string, unknown>;
const next: TTSSegmentManifestCursor = {
locatorReaderRank: Number(rec.locatorReaderRank),
locatorSpineIndex: Number(rec.locatorSpineIndex),
locatorCharOffset: Number(rec.locatorCharOffset),
locatorSpineHref: typeof rec.locatorSpineHref === 'string' ? rec.locatorSpineHref : '',
locatorPage: Number(rec.locatorPage),
locatorLocation: typeof rec.locatorLocation === 'string' ? rec.locatorLocation : '',
segmentIndex: Number(rec.segmentIndex),
locatorIdentityKey: typeof rec.locatorIdentityKey === 'string' ? rec.locatorIdentityKey : '',
segmentEntryId: typeof rec.segmentEntryId === 'string' ? rec.segmentEntryId : '',
};
if (!Number.isFinite(next.locatorReaderRank)) return null;
if (!Number.isFinite(next.locatorSpineIndex)) return null;
if (!Number.isFinite(next.locatorCharOffset)) return null;
if (!Number.isFinite(next.locatorPage)) return null;
if (!Number.isFinite(next.segmentIndex)) return null;
if (!next.locatorIdentityKey) return null;
if (!next.segmentEntryId) return null;
const normalizedInput = cursor.replace(/=+$/, '');
if (encodeManifestCursor(next) !== normalizedInput) {
return null;
}
return next;
} catch {
return null;
}
}
export function encodeManifestCursor(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url');
export function encodeManifestCursor(value: TTSSegmentManifestCursor): string {
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
}
export function parseManifestPageSize(value: string | null): number {

View file

@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import { locatorIdentityKey } from '@/lib/shared/tts-locator';
import { ffprobeAudio } from '@/lib/server/audiobooks/chapters';
import type {
TTSSegmentLocator,
@ -54,6 +55,17 @@ export function normalizeSegmentText(text: string): string {
return preprocessSentenceForAudio(text || '').trim();
}
export type TTSSegmentLocatorProjection = {
locatorReaderRank: number;
locatorReaderType: string;
locatorPage: number;
locatorSpineIndex: number;
locatorSpineHref: string;
locatorCharOffset: number;
locatorLocation: string;
locatorIdentityKey: string;
};
/**
* Validate and shape a locator for persistence. EPUB locators MUST carry the
* stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) the
@ -104,27 +116,51 @@ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSeg
return null;
}
export function projectSegmentLocator(locator: TTSSegmentLocator): TTSSegmentLocatorProjection {
if (locator.readerType === 'epub') {
return {
locatorReaderRank: 0,
locatorReaderType: 'epub',
locatorPage: -1,
locatorSpineIndex: typeof locator.spineIndex === 'number' ? locator.spineIndex : -1,
locatorSpineHref: typeof locator.spineHref === 'string' ? locator.spineHref : '',
locatorCharOffset: typeof locator.charOffset === 'number' ? locator.charOffset : -1,
locatorLocation: '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
if (locator.readerType === 'pdf') {
return {
locatorReaderRank: 1,
locatorReaderType: 'pdf',
locatorPage: typeof locator.page === 'number' ? Math.floor(locator.page) : -1,
locatorSpineIndex: -1,
locatorSpineHref: '',
locatorCharOffset: -1,
locatorLocation: '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
if (locator.readerType === 'html') {
return {
locatorReaderRank: 2,
locatorReaderType: 'html',
locatorPage: -1,
locatorSpineIndex: -1,
locatorSpineHref: '',
locatorCharOffset: -1,
locatorLocation: typeof locator.location === 'string' ? locator.location : '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
throw new Error(`Unsupported segment locator readerType for projection: ${String(locator.readerType)}`);
}
export function locatorFingerprint(locator: TTSSegmentLocator | null): string {
if (!locator) return '';
return createHash('sha256').update(stableStringify(locator)).digest('hex');
}
export function canonicalLocatorJson(locator: TTSSegmentLocator | null | undefined): string | null {
if (!locator) return null;
return stableStringify(locator);
}
export function canonicalizeLocatorJsonString(json: string | null | undefined): string | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
if (parsed === null || typeof parsed !== 'object') return null;
return stableStringify(parsed);
} catch {
return null;
}
}
export function buildTtsSegmentId(input: {
documentId: string;
documentVersion: number;
@ -146,6 +182,25 @@ export function buildTtsSegmentId(input: {
return createHash('sha256').update(canonical).digest('hex');
}
export function buildTtsSegmentEntryId(input: {
documentId: string;
documentVersion: number;
segmentIndex: number;
segmentKey?: string | null;
locatorIdentityKey: string;
textHash: string;
}): string {
const canonical = stableStringify({
d: input.documentId,
v: input.documentVersion,
i: input.segmentIndex,
k: input.segmentKey ? input.segmentKey : null,
l: input.locatorIdentityKey,
t: input.textHash,
});
return createHash('sha256').update(canonical).digest('hex');
}
export function buildTtsSegmentTextHash(text: string, secret: string): string {
return createHmac('sha256', secret).update(text).digest('hex');
}
@ -160,7 +215,7 @@ export function buildTtsSegmentAudioKey(input: {
segmentId: string;
}): string {
const nsSegment = input.namespace ? `ns/${input.namespace}/` : '';
return `${input.storagePrefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`;
return `${input.storagePrefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`;
}
export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise<number> {

View file

@ -92,8 +92,10 @@ export async function deleteUserStorageData(
try {
const cfg = getS3Config();
const nsSegment = namespace ? `ns/${namespace}/` : '';
const ttsPrefix = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`;
segmentsDeleted = await deleteTtsSegmentPrefix(ttsPrefix);
const ttsPrefixV1 = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`;
const ttsPrefixV2 = `${cfg.prefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(userId)}/`;
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1);
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2);
} catch (error) {
console.error(`[user-data-cleanup] Failed to delete TTS segment blobs for user ${userId}:`, error);
}

View file

@ -76,7 +76,8 @@ export default async function globalTeardown(): Promise<void> {
const config = getS3Config();
const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`;
const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`;
const ttsSegmentsNsRootPrefix = `${config.prefix}/tts_segments_v1/ns/`;
const ttsSegmentsNsRootPrefixV1 = `${config.prefix}/tts_segments_v1/ns/`;
const ttsSegmentsNsRootPrefixV2 = `${config.prefix}/tts_segments_v2/ns/`;
// Remove SQL audiobook rows for namespaced objects (covers auth claim flows too).
const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix);
@ -105,5 +106,6 @@ export default async function globalTeardown(): Promise<void> {
await deleteDocumentPrefix(docsNsRootPrefix);
await deleteAudiobookPrefix(audiobooksNsRootPrefix);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefix);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV1);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV2);
}

View file

@ -157,10 +157,22 @@ test.describe('tts segments manifest helpers', () => {
});
test('encodes and decodes cursors', () => {
const raw = '3|p:2|l:epubcfi(/6/2)|r:epub';
const raw = {
locatorReaderRank: 0,
locatorSpineIndex: 2,
locatorCharOffset: 128,
locatorSpineHref: 'OEBPS/ch02.xhtml',
locatorPage: -1,
locatorLocation: '',
segmentIndex: 4,
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
segmentEntryId: 'entry-4',
};
const encoded = encodeManifestCursor(raw);
expect(decodeManifestCursor(encoded)).toBe(raw);
expect(decodeManifestCursor(encoded)).toEqual(raw);
expect(decodeManifestCursor('not-base64')).toBeNull();
const malformed = Buffer.from(JSON.stringify({ bad: true }), 'utf8').toString('base64url');
expect(decodeManifestCursor(malformed)).toBeNull();
});
test('clamps page size bounds', () => {

View file

@ -1,12 +1,14 @@
import { expect, test } from '@playwright/test';
import {
buildProportionalAlignment,
buildTtsSegmentEntryId,
buildTtsSegmentId,
buildTtsSegmentSettingsHash,
buildTtsSegmentTextHash,
locatorFingerprint,
normalizeLocator,
normalizeSegmentText,
projectSegmentLocator,
} from '../../src/lib/server/tts/segments';
test.describe('tts segment helpers', () => {
@ -57,6 +59,35 @@ test.describe('tts segment helpers', () => {
expect(id1).not.toBe(id3);
});
test('builds deterministic segment entry id independent of settings', () => {
const entry1 = buildTtsSegmentEntryId({
documentId: 'doc',
documentVersion: 1,
segmentIndex: 2,
segmentKey: 'doc:v1:segment-a',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
textHash: 'abc123',
});
const entry2 = buildTtsSegmentEntryId({
documentId: 'doc',
documentVersion: 1,
segmentIndex: 2,
segmentKey: 'doc:v1:segment-a',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
textHash: 'abc123',
});
const entry3 = buildTtsSegmentEntryId({
documentId: 'doc',
documentVersion: 1,
segmentIndex: 2,
segmentKey: 'doc:v1:segment-b',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
textHash: 'abc123',
});
expect(entry1).toBe(entry2);
expect(entry1).not.toBe(entry3);
});
test('canonical segment key makes id independent of locator and index', () => {
const id1 = buildTtsSegmentId({
documentId: 'doc',
@ -97,8 +128,15 @@ test.describe('tts segment helpers', () => {
test('normalizes PDF locators and creates fingerprints', () => {
const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 });
if (!locator) throw new Error('expected normalized pdf locator');
expect(locator).toEqual({ readerType: 'pdf', page: 2 });
expect(locatorFingerprint(locator)).toHaveLength(64);
expect(projectSegmentLocator(locator)).toMatchObject({
locatorReaderRank: 1,
locatorReaderType: 'pdf',
locatorPage: 2,
locatorIdentityKey: 'pdf:2',
});
});
test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => {
@ -110,6 +148,7 @@ test.describe('tts segment helpers', () => {
charOffset: 128.4,
cfi: ' epubcfi(/6/4!/4:0) ',
});
if (!stable) throw new Error('expected normalized epub locator');
expect(stable).toEqual({
readerType: 'epub',
spineHref: 'OEBPS/ch02.xhtml',
@ -118,6 +157,14 @@ test.describe('tts segment helpers', () => {
cfi: 'epubcfi(/6/4!/4:0)',
});
expect(locatorFingerprint(stable)).toHaveLength(64);
expect(projectSegmentLocator(stable)).toMatchObject({
locatorReaderRank: 0,
locatorReaderType: 'epub',
locatorSpineIndex: 2,
locatorCharOffset: 128,
locatorSpineHref: 'OEBPS/ch02.xhtml',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
});
// Legacy/draft EPUB shape (CFI in `location` but no spine coords) is
// rejected so we never persist viewport-dependent locators.
@ -125,6 +172,15 @@ test.describe('tts segment helpers', () => {
expect(legacy).toBeNull();
});
test('projects html locators into stable manifest sort fields', () => {
expect(projectSegmentLocator({ readerType: 'html', location: '#intro' })).toMatchObject({
locatorReaderRank: 2,
locatorReaderType: 'html',
locatorLocation: '#intro',
locatorIdentityKey: 'html:#intro',
});
});
test('builds proportional alignment preserving order', () => {
const alignment = buildProportionalAlignment({
sentence: normalizeSegmentText('Hello world again'),