Fix chunk assembly order and surface video duration in the optimizer
- The streaming assembler applied chunks sorted by position, so header patches written last could be overwritten by earlier data. Chunks now apply in arrival order (newest write wins per byte range) and are only laid out by position afterwards. - The Optimize modal now reads the media metadata itself: both previews show resolution and duration, and the processed video is flagged in red when it comes out shorter than the original (a truncated encode is no longer something you have to guess by file size).
This commit is contained in:
@@ -68,23 +68,36 @@ function formatEta(ms: number): string {
|
|||||||
return `${minutes} min left`;
|
return `${minutes} min left`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function PreviewPanel({
|
function PreviewPanel({
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
isVideo,
|
isVideo,
|
||||||
size,
|
size,
|
||||||
dimensions,
|
|
||||||
note,
|
note,
|
||||||
state,
|
state,
|
||||||
|
onMetadata,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
dimensions: { width: number | null; height: number | null } | null;
|
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
state: "original" | "result" | "empty";
|
state: "original" | "result" | "empty";
|
||||||
|
onMetadata?: (info: MediaInfo) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [info, setInfo] = useState<MediaInfo | null>(null);
|
||||||
|
|
||||||
|
function report(next: MediaInfo) {
|
||||||
|
setInfo(next);
|
||||||
|
onMetadata?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
|
<section className="flex flex-col overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
|
||||||
<div className="flex items-center justify-between border-b border-ctp-surface0 px-3 py-1.5">
|
<div className="flex items-center justify-between border-b border-ctp-surface0 px-3 py-1.5">
|
||||||
@@ -99,18 +112,38 @@ function PreviewPanel({
|
|||||||
{url && state !== "empty" ? (
|
{url && state !== "empty" ? (
|
||||||
isVideo ? (
|
isVideo ? (
|
||||||
<video
|
<video
|
||||||
|
key={url}
|
||||||
src={url}
|
src={url}
|
||||||
controls
|
controls
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
className="max-h-56 w-full object-contain"
|
className="max-h-56 w-full object-contain"
|
||||||
|
onLoadedMetadata={(event) => {
|
||||||
|
const element = event.currentTarget;
|
||||||
|
report({
|
||||||
|
width: element.videoWidth,
|
||||||
|
height: element.videoHeight,
|
||||||
|
duration: Number.isFinite(element.duration)
|
||||||
|
? element.duration
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
|
key={url}
|
||||||
src={url}
|
src={url}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="max-h-56 w-full object-contain"
|
className="max-h-56 w-full object-contain"
|
||||||
|
onLoad={(event) => {
|
||||||
|
const element = event.currentTarget;
|
||||||
|
report({
|
||||||
|
width: element.naturalWidth,
|
||||||
|
height: element.naturalHeight,
|
||||||
|
duration: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -122,8 +155,10 @@ function PreviewPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 text-[11px] text-ctp-overlay0">
|
<div className="px-3 py-2 text-[11px] text-ctp-overlay0">
|
||||||
{dimensions?.width && dimensions?.height
|
{info
|
||||||
? `${dimensions.width}×${dimensions.height}`
|
? `${info.width}×${info.height}${
|
||||||
|
info.duration !== null ? ` · ${info.duration.toFixed(1)} s` : ""
|
||||||
|
}`
|
||||||
: "…"}
|
: "…"}
|
||||||
{note ? ` · ${note}` : ""}
|
{note ? ` · ${note}` : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,6 +185,8 @@ export function OptimizeModal({
|
|||||||
const [uploadPercent, setUploadPercent] = useState(0);
|
const [uploadPercent, setUploadPercent] = useState(0);
|
||||||
const [applyError, setApplyError] = useState<string | null>(null);
|
const [applyError, setApplyError] = useState<string | null>(null);
|
||||||
const [applied, setApplied] = useState(false);
|
const [applied, setApplied] = useState(false);
|
||||||
|
const [originalInfo, setOriginalInfo] = useState<MediaInfo | null>(null);
|
||||||
|
const [resultInfo, setResultInfo] = useState<MediaInfo | null>(null);
|
||||||
|
|
||||||
const codecQuery = useQuery({
|
const codecQuery = useQuery({
|
||||||
queryKey: ["optimize-codecs"],
|
queryKey: ["optimize-codecs"],
|
||||||
@@ -204,10 +241,16 @@ export function OptimizeModal({
|
|||||||
const hardwareAvailable =
|
const hardwareAvailable =
|
||||||
codecQuery.data?.some((entry) => entry.supported && entry.hardware) ??
|
codecQuery.data?.some((entry) => entry.supported && entry.hardware) ??
|
||||||
false;
|
false;
|
||||||
|
const truncated =
|
||||||
|
isVideo &&
|
||||||
|
originalInfo?.duration != null &&
|
||||||
|
resultInfo?.duration != null &&
|
||||||
|
resultInfo.duration + 1 < originalInfo.duration;
|
||||||
|
|
||||||
function process() {
|
function process() {
|
||||||
setApplied(false);
|
setApplied(false);
|
||||||
setApplyError(null);
|
setApplyError(null);
|
||||||
|
setResultInfo(null);
|
||||||
run({
|
run({
|
||||||
url: item.raw_url,
|
url: item.raw_url,
|
||||||
filename: item.filename,
|
filename: item.filename,
|
||||||
@@ -257,24 +300,29 @@ export function OptimizeModal({
|
|||||||
url={item.raw_url}
|
url={item.raw_url}
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
size={originalSize}
|
size={originalSize}
|
||||||
dimensions={{ width: null, height: null }}
|
|
||||||
state="original"
|
state="original"
|
||||||
|
onMetadata={setOriginalInfo}
|
||||||
/>
|
/>
|
||||||
<PreviewPanel
|
<PreviewPanel
|
||||||
title="Processed"
|
title="Processed"
|
||||||
url={result?.url ?? null}
|
url={result?.url ?? null}
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
size={resultSize}
|
size={resultSize}
|
||||||
dimensions={
|
|
||||||
result
|
|
||||||
? { width: result.width, height: result.height }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
note={result?.note ?? null}
|
note={result?.note ?? null}
|
||||||
state={result ? "result" : "empty"}
|
state={result ? "result" : "empty"}
|
||||||
|
onMetadata={setResultInfo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{truncated ? (
|
||||||
|
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
|
||||||
|
The processed video is shorter than the original (
|
||||||
|
{resultInfo?.duration?.toFixed(1)} s vs{" "}
|
||||||
|
{originalInfo?.duration?.toFixed(1)} s) — encoding may have
|
||||||
|
stopped early. Don't apply it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{status === "running" ? (
|
{status === "running" ? (
|
||||||
<div className="rounded-md border border-ctp-surface1 bg-ctp-base p-3">
|
<div className="rounded-md border border-ctp-surface1 bg-ctp-base p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ function assembleBlob(chunks: CollectedChunk[], type: string): Blob {
|
|||||||
if (chunks.length === 0) {
|
if (chunks.length === 0) {
|
||||||
throw new Error("Encoding produced no output.");
|
throw new Error("Encoding produced no output.");
|
||||||
}
|
}
|
||||||
const sorted = [...chunks].sort(
|
// The muxer may rewrite earlier ranges (header patching), so apply writes
|
||||||
(a, b) => a.position - b.position || a.sequence - b.sequence,
|
// in arrival order — the newest write wins per byte range — and only then
|
||||||
);
|
// lay the segments out by position.
|
||||||
|
const ordered = [...chunks].sort((a, b) => a.sequence - b.sequence);
|
||||||
|
|
||||||
// Overwrite ranges so the newest write for each byte range survives.
|
|
||||||
interface Segment {
|
interface Segment {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
@@ -80,7 +80,7 @@ function assembleBlob(chunks: CollectedChunk[], type: string): Blob {
|
|||||||
blob: Blob;
|
blob: Blob;
|
||||||
}
|
}
|
||||||
let segments: Segment[] = [];
|
let segments: Segment[] = [];
|
||||||
for (const chunk of sorted) {
|
for (const chunk of ordered) {
|
||||||
const start = chunk.position;
|
const start = chunk.position;
|
||||||
const end = start + chunk.blob.size;
|
const end = start + chunk.blob.size;
|
||||||
const next: Segment[] = [];
|
const next: Segment[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user