Repository navigation
fix(studio): trimming a scene no longer shrinks it to the length of a video inside - #4421
Conversation
… video inside A clip's source length came from the first video, audio or image found inside its element. For a composition that is media on the scene's own timeline, so a scene holding a 1.8 s video could not be longer than 1.8 s: trimming its end by a few pixels collapsed it to that length. A composition host no longer lends its inner media to the clip; a plain wrapper around one video still does. An inline composition still shows its inner video or image as its thumbnail.
terencecho
left a comment
There was a problem hiding this comment.
Approved at 2222ff0c. Fixes the "trim collapses a scene to the length of its inner video" bug. Narrow, scoped, and structurally pinned.
Scope — 3 files, ~101 line delta.
packages/studio/src/player/lib/timelineElementHelpers.ts+10/-0 — newisCompositionHost(el)predicate;resolveMediaElementearly-returnsnullwhen its input is a composition host, before falling through to thequerySelector("video, audio, img")inner-media lookup.packages/studio/src/player/lib/timelineDOM.ts+5/-5 — inline-composition thumbnail branch broadened fromvideo[src]tovideo[src], img[src], withtagchosen by tagName.packages/studio/src/player/lib/timelineDOM.test.ts+86/-0 — newdescribe("a composition clip's source length")with 4 tests locking the fix + the non-regression.
Fix 1 — the scene-trim collapse.
Bug: resolveMediaElement walked querySelector("video, audio, img") on any wrapper without a MediaElement self, returning the FIRST inner media it found. For a scene composition holding a 1.77s inner video, that inner video's data-source-duration=1.77 propagated to entry.sourceDuration via applyMediaMetadataFromElement. Then timelineClipDragPreview.ts:268-271 caps the trim preview at (sourceDuration - playbackStart) / playbackRate. Dragging the scene's end even a few pixels shorter hit that cap → the scene saved at 1.77s.
Fix mechanism (three narrow touches):
isCompositionHost(el)checks any of the three composition-host attributes (data-composition-id,data-composition-src,data-composition-file). The three-attribute set matches how the rest of studio already identifies composition hosts (LayersPanel.tsx:60,domEditOverlayGeometry.ts:57,domEditingDom.ts:182,domEditing.tsselectors).resolveMediaElementreturnsnullwhenisCompositionHost(el)is true, before thequerySelectorfallback. The "el is itself aMediaElement/ImageElement" branch above it is untouched — a bare<video>or<img>still returns itself.- Downstream: with
mediaEl = null,applyMediaMetadataFromElementshort-circuits atif (!mediaEl) return, soentry.sourceDuration/entry.src/entry.tagnever get set from the composition path.timelineDOM.ts:299's follow-upif (mediaEl)block is skipped for the same reason.
Non-loosening property — every consumer of sourceDuration checked at head:
timelineClipDragPreview.ts:268-271(trim cap):sourceDuration != nullguards the cap;nullmeans "no cap" — the intended fix.timelineEditCapabilities.ts:17-19(canTrimEnd): null-sourceDuration falls through to "trim allowed" — same intent.propertyPanelFlatMediaSection.tsx:98,102&propertyPanelMediaSection.tsx:74,78(slider max):sourceDuration || mediaStart + 10falls back cleanly. Composition media doesn't own its playback, so this is semantically correct.useTimelinePlayer.ts:114-124(async enrichment): skips entries withsourceDuration != null. For compositions, tag/src are also null → the enrichment loop'sif (el.src && (tag === "audio" || tag === "video"))guard skips them (verified atmediaProbe.ts:113-117). No stray re-probe writessourceDurationback onto a composition.useRenderClipContent.ts:57,timelinePlayerSync.ts:17,timelineGroupEditing.ts:200,timelineAuthoredMoveTarget.ts:18— readsourceDurationand forward or compare it;nullis a stable value across all sites.mediaProbe.tscache re-application (applyProbedDurations) filters bytag === "audio" || "video"— composition entries withtagunset are never touched.
The plain-wrapper case is the important non-regression: a .clip <div> wrapping a <video src=talk.mp4 data-source-duration=4> has NO composition attribute, so isCompositionHost is false, querySelector returns the inner video, sourceDuration=4 is preserved. Test 1 asserts this side-by-side with the composition host in the same DOM.
Fix 2 — inline composition can now surface an inner image as its thumbnail.
timelineDOM.ts:346-351 — the inline-composition branch (no compSrc, non-root compId) previously only picked up video[src]. An inline composition whose only media was <img> got no thumbnail. Broadened to video[src], img[src]; tag is "img" when the first hit is an IMG, else "video". Video-inside behavior unchanged (still picks the first video[src] if present).
The concern I checked: does the img broadening ever steal a decorative image from a legit composition-with-src? No — that branch is else if on compSrc being absent. If compositionSrc is set (external composition), the whole thumbnail-inner-media path is skipped and entry.compositionSrc drives the thumbnail. Only inline compositions (compId set, no compSrc) reach the inner-media lookup, and they legitimately want their inner image as the thumbnail.
Test coverage — mutation-verified per PR body:
- "is not taken from media inside the composition, parsed from the DOM" — parses a mixed DOM: composition host with inner
<video data-source-duration=1.77>+ a.clipwrapper with<video data-source-duration=4>. Asserts host hassourceDuration = undefinedandtag != "video", wrapper keepssourceDuration = 4. RevertingisCompositionHostinresolveMediaElementturns this red on the host assertion; the wrapper assertion pins the non-regression. - "does not cap trimming the composition's end at that media's length" — exercises
computeResizePreviewend-trim on the parsed 18s host with a 330px delta @ 20pps → expects 16.5s. On main this would cap at 1.77s (the inner video's source length); on head it flows through freely. Removing the composition-host guard turns this red at the numeric assertion. - "is not taken from media inside the composition, from the runtime manifest" — parallel to test 1 via
createTimelineElementFromManifestClip. Asserts hostsourceDuration = undefinedandsrc = undefined. Locks the manifest path too, which is the path Studio actually uses at load. - "still shows an inline composition's image as its thumbnail" — parses an inline composition holding only an
<img src=card.png>; expectstag = "img",src = "card.png",sourceDuration = undefined. Reverting Fix 2 (video[src]only) turns this red exactly.
Before/After captures — mechanism confirmed independently. The PR body links two GitHub user-attachment screenshots (18s scene → 1.8s sliver on main; same steps → 15.33s with thumbnails on head). I can't visually verify remote images, but the mechanism the images demonstrate is byte-pinned by tests 1, 2, and 4 — the cap comes off the composition, the trim math flows through, and the inline-image thumbnail path works. That's stronger evidence than a rendered screenshot.
CI at head 2222ff0c. All fast lanes PASS: Test (studio), Test (runtime), Test (support), Typecheck, Lint, Format, Preflight, Build, CLI smoke (required), Fallow audit, Studio: load smoke, Studio: timeline viewport gate, Test reachability, Studio and player captures, Preview parity, player-perf, preview-regression, regression, Analyze (actions)/(python), SDK: unit + contract + smoke, Test: runtime contract, Producer: unit/integration tests, Semantic PR title, File size check, Graphite mergeability_check. Pending at stamp: Windows heavy lanes (Tests on windows-latest: studio-core / studio-engine-cli, Render on windows-latest), Analyze (javascript-typescript) [CodeQL js/ts is always the slowest], Smoke: global install. No FAILUREs. Stamping on merit.
require_last_push_approval gate cleared. Single commit 2222ff0c authored + committed by miguel-heygen at 14:52:00Z. tai ≠ last pusher.
— Review by tai (pr-review)
What
A timeline clip's source length is read from its element: the element itself when it is a video, audio or image, otherwise the first one found inside it. The trim preview then caps a clip's end at that length. For a composition clip, the media inside belongs to the composition's own timeline, not to the clip. So a scene that holds a short video could never be longer than that video, and trimming its end by a few pixels collapsed it to the video's length and saved that.
resolveMediaElementno longer looks inside a composition host (data-composition-id,data-composition-srcordata-composition-file). A composition clip gets no source length and is trimmed freely. A plain wrapper around one video still takes its length from that video. Composition thumbnails are unaffected: they key oncompositionSrc, which is set separately. An inline composition (no source file) used to get its thumbnail from that same lookup when its only media was an image; the inline branch that already exposed an inner video for thumbnails now exposes an inner image too.Tests
In
timelineDOM.test.ts, a scene composition holding a 1.77 s video next to a wrapper clip holding a 4 s video:computeResizePreview, not the video's length.With the fix,
timelineDOM.test.tsandtimelineClipDragPreview.test.tspass, 39 of 39. On the previous helper, exactly the three new tests fail (the other tests pass). oxlint: 0 warnings, 0 errors. oxfmt: clean. Studio typecheck: clean.Before
Studio dev server, one 18 s scene composition whose template holds a 1.8 s video. Drag the scene's right edge 60 px left (about 2.7 s), then reload. The scene is saved as 1.8 s long.
After
Same steps with this change: the scene is saved as 15.33 s long and keeps its thumbnails.