Skip to content

fix(studio): trimming a scene no longer shrinks it to the length of a video inside - #4421

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/studio-composition-clip-source-length
Sep 24, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/studio-composition-clip-source-length

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

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.

resolveMediaElement no longer looks inside a composition host (data-composition-id, data-composition-src or data-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 on compositionSrc, 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:

  • Parsed from the DOM, the scene has no source length and no video tag. The wrapper keeps 4 s.
  • An end trim of the 18 s scene gives 16.5 s through computeResizePreview, not the video's length.
  • Built from the runtime manifest, the scene has no source length or src.
  • An inline composition holding only an image still gets that image as its thumbnail. Reverting the inline branch to video only fails this test alone.

With the fix, timelineDOM.test.ts and timelineClipDragPreview.test.ts pass, 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.

Before: after the trim the scene is a 1.8 s sliver on the timeline

After

Same steps with this change: the scene is saved as 15.33 s long and keeps its thumbnails.

After: the scene is 15.33 s long with its thumbnails

… 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 terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — new isCompositionHost(el) predicate; resolveMediaElement early-returns null when its input is a composition host, before falling through to the querySelector("video, audio, img") inner-media lookup.
  • packages/studio/src/player/lib/timelineDOM.ts +5/-5 — inline-composition thumbnail branch broadened from video[src] to video[src], img[src], with tag chosen by tagName.
  • packages/studio/src/player/lib/timelineDOM.test.ts +86/-0 — new describe("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):

  1. 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.ts selectors).
  2. resolveMediaElement returns null when isCompositionHost(el) is true, before the querySelector fallback. The "el is itself a MediaElement/ImageElement" branch above it is untouched — a bare <video> or <img> still returns itself.
  3. Downstream: with mediaEl = null, applyMediaMetadataFromElement short-circuits at if (!mediaEl) return, so entry.sourceDuration / entry.src / entry.tag never get set from the composition path. timelineDOM.ts:299's follow-up if (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 != null guards the cap; null means "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 + 10 falls back cleanly. Composition media doesn't own its playback, so this is semantically correct.
  • useTimelinePlayer.ts:114-124 (async enrichment): skips entries with sourceDuration != null. For compositions, tag/src are also null → the enrichment loop's if (el.src && (tag === "audio" || tag === "video")) guard skips them (verified at mediaProbe.ts:113-117). No stray re-probe writes sourceDuration back onto a composition.
  • useRenderClipContent.ts:57, timelinePlayerSync.ts:17, timelineGroupEditing.ts:200, timelineAuthoredMoveTarget.ts:18 — read sourceDuration and forward or compare it; null is a stable value across all sites.
  • mediaProbe.ts cache re-application (applyProbedDurations) filters by tag === "audio" || "video" — composition entries with tag unset 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 .clip wrapper with <video data-source-duration=4>. Asserts host has sourceDuration = undefined and tag != "video", wrapper keeps sourceDuration = 4. Reverting isCompositionHost in resolveMediaElement turns 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 computeResizePreview end-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 host sourceDuration = undefined and src = 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>; expects tag = "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)

@miguel-heygen
miguel-heygen merged commit a3d7736 into main Sep 24, 2026
54 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-composition-clip-source-length branch September 24, 2026 15:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants