@bycrux/editor 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +385 -4
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +345 -23
- package/src/index.ts +8 -0
- package/src/schema.ts +41 -1
- package/src/video/VideoEditor.tsx +106 -10
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/useVideoPlayback.ts +18 -3
- package/src/video/timeline/Timeline.tsx +49 -1
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
- package/src/video/timeline/canvas/draw.ts +104 -7
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +142 -5
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +286 -15
package/src/video/cuts.ts
CHANGED
|
@@ -481,19 +481,47 @@ function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack
|
|
|
481
481
|
const outPoint = track.outPoint ?? track.sourceDuration ?? (track.end - track.start)
|
|
482
482
|
const splitPoint = inPoint + (at - track.start)
|
|
483
483
|
|
|
484
|
+
// A FADE BELONGS TO AN EDGE, NOT TO THE CLIP. Spreading `...track` into both
|
|
485
|
+
// halves gave both fragments both fades, which is wrong on the two edges the
|
|
486
|
+
// split just created: the left half fades OUT into the cut and the right half
|
|
487
|
+
// fades IN out of it, neither of which anyone asked for. On a voiceover
|
|
488
|
+
// carrying the usual trailing fade-out that is very visible — every cut
|
|
489
|
+
// produced a fragment that ramps to silence at the razor, and the operator
|
|
490
|
+
// has to zero it by hand each time. Fades are added deliberately; a cut is
|
|
491
|
+
// not a request for one.
|
|
492
|
+
//
|
|
493
|
+
// So each half keeps only the fade whose EDGE it still owns:
|
|
494
|
+
// • the left half keeps the original START, so it keeps `fadeIn`;
|
|
495
|
+
// • the right half keeps the original END, so it keeps `fadeOut`;
|
|
496
|
+
// • the two new edges at the cut carry no fade at all.
|
|
497
|
+
// The paired curve goes with its fade — a `fadeOutCurve` describing a
|
|
498
|
+
// fade-out that no longer exists is dead data, and the operator's next
|
|
499
|
+
// hand-drawn fade should start from the default shape like any other new one.
|
|
500
|
+
//
|
|
501
|
+
// The kept fade is then clamped to its own fragment's span. A 3s fade-out on
|
|
502
|
+
// a 20s track is fine; split that track 1s from its end and the 3s fade no
|
|
503
|
+
// longer fits inside the 1s fragment it lands on. Nothing downstream
|
|
504
|
+
// re-clamps for us — the pointer-machine and the properties panel each clamp
|
|
505
|
+
// their OWN edits, and the renderer just anchors `st = end - fadeOut`, which
|
|
506
|
+
// for an oversized fade starts before the fragment's audio does.
|
|
507
|
+
const { fadeOut: _outAmt, fadeOutCurve: _outCurve, ...leftBase } = track
|
|
508
|
+
const { fadeIn: _inAmt, fadeInCurve: _inCurve, ...rightBase } = track
|
|
509
|
+
|
|
484
510
|
const left: AudioTrack = {
|
|
485
|
-
...
|
|
511
|
+
...leftBase,
|
|
486
512
|
end: at,
|
|
487
513
|
inPoint,
|
|
488
514
|
outPoint: splitPoint,
|
|
489
515
|
}
|
|
490
516
|
const right: AudioTrack = {
|
|
491
|
-
...
|
|
517
|
+
...rightBase,
|
|
492
518
|
id: uniqueId(track.id),
|
|
493
519
|
start: at,
|
|
494
520
|
inPoint: splitPoint,
|
|
495
521
|
outPoint,
|
|
496
522
|
}
|
|
523
|
+
if (left.fadeIn !== undefined) left.fadeIn = Math.min(left.fadeIn, at - track.start)
|
|
524
|
+
if (right.fadeOut !== undefined) right.fadeOut = Math.min(right.fadeOut, track.end - at)
|
|
497
525
|
return [left, right]
|
|
498
526
|
}
|
|
499
527
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
|
2
|
-
import { containsTime, geometryAt, geometryFor } from '@bycrux/timeline-core'
|
|
2
|
+
import { containsTime, geometryAt, geometryFor, resolveAt } from '@bycrux/timeline-core'
|
|
3
3
|
import { isProxyUsable, markProxyFailed } from './proxySupport'
|
|
4
4
|
import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
5
5
|
import type { OverlayFactory } from '../../types'
|
|
@@ -8,7 +8,7 @@ import { getOverlayDesignCanvas } from '../design-canvas'
|
|
|
8
8
|
import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
|
|
9
9
|
import type { Corner, Edge, OverlayChanges } from './useDragOverlay'
|
|
10
10
|
import type { useDragOverlay } from './useDragOverlay'
|
|
11
|
-
import { enabledTrackItems } from '../timeline/timeline-model'
|
|
11
|
+
import { enabledTrackItems, withEnabledItemTracks } from '../timeline/timeline-model'
|
|
12
12
|
|
|
13
13
|
// Mount video items this many seconds before item.start so the frame is ready.
|
|
14
14
|
//
|
|
@@ -421,6 +421,62 @@ export default function OverlayItemsLayer({
|
|
|
421
421
|
// Enabled only: a skipped track must not be draggable in the preview either.
|
|
422
422
|
const interactiveTracks = isCanvasProject ? enabledTrackItems(project) : overlayTracks
|
|
423
423
|
|
|
424
|
+
// Split from the `scene` memo below ON PURPOSE. `withEnabledItemTracks`
|
|
425
|
+
// spreads the project and re-runs `normalizeTracks` plus a `.map()` over every
|
|
426
|
+
// track — work that depends on `project` alone. Folded into one memo keyed
|
|
427
|
+
// `[project, currentTime]` it re-ran on EVERY frame of playback, rebuilding an
|
|
428
|
+
// identical track array 30-60 times a second. Keyed on `project` it runs once
|
|
429
|
+
// per edit, and only the genuinely time-dependent `resolveAt` runs per frame.
|
|
430
|
+
const previewProject = useMemo(() => withEnabledItemTracks(project), [project])
|
|
431
|
+
|
|
432
|
+
// The resolver's Scene at this instant — the SAME `resolveAt` call the canvas
|
|
433
|
+
// engine's `previewResolver` makes (engine/scheduler.ts), so this layer and
|
|
434
|
+
// the engine read the identical `crossfade` stamp for a clip that happens to
|
|
435
|
+
// sit on an overlay track. `ResolvedItem.item` is the project's own object by
|
|
436
|
+
// reference (timeline-core's contract), so the blocks below look their
|
|
437
|
+
// resolved counterparts up by identity rather than recomputing anything.
|
|
438
|
+
const scene = useMemo(
|
|
439
|
+
() => resolveAt(previewProject, currentTime, { variant: 'preview' }),
|
|
440
|
+
[previewProject, currentTime],
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
// The transition weight for ONE clip at this instant, shared by BOTH render
|
|
444
|
+
// blocks below — the tracks[0] images and the interactive tracks. Both are
|
|
445
|
+
// fed by `enabledTrackItems(project)` (`useVideoPlayback.ts` / this file's own
|
|
446
|
+
// `previewProject`), so the identity lookup resolves in either.
|
|
447
|
+
//
|
|
448
|
+
// ONLY THE INCOMING SIDE RAMPS. The outgoing side keeps its own full opacity —
|
|
449
|
+
// factor 1, not `1-p` — and that is not a shortcut, it is what this kind of
|
|
450
|
+
// compositor requires:
|
|
451
|
+
//
|
|
452
|
+
// - The EXPORT is a SPLIT-AND-LERP compositor: `encode-segment.js` splits
|
|
453
|
+
// the canvas, composites each side down its own branch at its OWN full
|
|
454
|
+
// opacity, then lerps the two finished frames with
|
|
455
|
+
// `blend=all_expr='A+(B-A)*p'` — `(1-p)*from + p*to`.
|
|
456
|
+
// - THIS layer is a SEQUENTIAL source-over compositor: each item is a DOM
|
|
457
|
+
// node painted over the ones before it. Leaving `from` at 1 and giving
|
|
458
|
+
// `to` alpha p produces exactly `p*to + (1-p)*from` — the same lerp,
|
|
459
|
+
// reached by a different route.
|
|
460
|
+
//
|
|
461
|
+
// Feeding the symmetric `1-p` / `p` pair into a sequential stack does NOT
|
|
462
|
+
// reproduce the lerp; it yields `p*to + (1-p)^2*from + p(1-p)*bg`, which at
|
|
463
|
+
// p=0.5 shows the outgoing clip at QUARTER weight and lets the background leak
|
|
464
|
+
// through the middle of every transition.
|
|
465
|
+
//
|
|
466
|
+
// `createCanvasPainter.paintBlend` (engine/index.ts) is the shared reference
|
|
467
|
+
// for the sequential form: `globalAlpha = 1` for `from`, then `globalAlpha = p`
|
|
468
|
+
// for `to`. `render/sample-frame.js` — also sequential — carries the same
|
|
469
|
+
// factor. Do not "restore" the symmetric form; it is the obvious-looking wrong
|
|
470
|
+
// answer for this compositor.
|
|
471
|
+
//
|
|
472
|
+
// `crossfade` is CLIPS-ONLY and null outside an overlap
|
|
473
|
+
// (@bycrux/timeline-core's `crossfadesAt`), so this returns 1 — a no-op —
|
|
474
|
+
// everywhere a transition is not actually in play.
|
|
475
|
+
const crossfadeFactor = (item: VisualItem) => {
|
|
476
|
+
const xf = scene.items.find((r) => r.item === item)?.crossfade ?? null
|
|
477
|
+
return xf?.role === 'to' ? xf.p : 1
|
|
478
|
+
}
|
|
479
|
+
|
|
424
480
|
return (
|
|
425
481
|
<>
|
|
426
482
|
{/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
|
|
@@ -438,9 +494,20 @@ export default function OverlayItemsLayer({
|
|
|
438
494
|
// buildImageItemFilterParts, which since SP9d compiles their curves into
|
|
439
495
|
// ffmpeg expressions. Leaving this branch on the static resolve would
|
|
440
496
|
// put back a preview/render divergence in the one place nobody would
|
|
441
|
-
// look for it.
|
|
497
|
+
// look for it. The opacity CURVE stays ignored here too — ffmpeg cannot
|
|
498
|
+
// vary clip alpha.
|
|
499
|
+
//
|
|
500
|
+
// A TRANSITION is the one thing that does move this opacity, and these
|
|
501
|
+
// items need it as much as the interactive block below does:
|
|
502
|
+
// `crossfadesAt` stamps IMAGE clips exactly like video ones, and the
|
|
503
|
+
// export blends a track[0] image pair through the same
|
|
504
|
+
// `encode-segment.js` `blend` branch. The canvas engine skips them
|
|
505
|
+
// (`scheduler.ts` filters its scan to `trackIdx === 0 && kind ===
|
|
506
|
+
// 'video'`), so this block is the ONLY thing drawing them in non-canvas
|
|
507
|
+
// mode — leave it static and the preview hard-cuts a pair the export
|
|
508
|
+
// dissolves. Same shared factor, same reasoning: see `crossfadeFactor`.
|
|
442
509
|
const gAnimated = geometryAt(item, 'image', currentTime - item.start)
|
|
443
|
-
const g = { ...gAnimated, opacity: geometryFor(item, 'image').opacity }
|
|
510
|
+
const g = { ...gAnimated, opacity: geometryFor(item, 'image').opacity * crossfadeFactor(item) }
|
|
444
511
|
const fit = g.fit ?? 'cover'
|
|
445
512
|
const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
|
|
446
513
|
const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
|
|
@@ -542,9 +609,21 @@ export default function OverlayItemsLayer({
|
|
|
542
609
|
// unaffected — they are baked per frame in a browser, where opacity is
|
|
543
610
|
// just another CSS value.
|
|
544
611
|
const animated = geometryAt(item, item.type, currentTime - item.start)
|
|
545
|
-
|
|
612
|
+
// SP9d's pin STANDS: a clip's opacity CURVE is still ignored here,
|
|
613
|
+
// because ffmpeg cannot animate clip alpha and preview must not
|
|
614
|
+
// promise a fade the export cannot produce.
|
|
615
|
+
//
|
|
616
|
+
// A TRANSITION is different, and is not an exception to that rule but
|
|
617
|
+
// an application of it: the export really does blend a transitioning
|
|
618
|
+
// pair (`encode-segment.js`'s `blend` branch), so showing the blend
|
|
619
|
+
// here is what keeps preview honest. The factor comes from the shared
|
|
620
|
+
// resolver via `crossfadeFactor` above — read its note for WHY only
|
|
621
|
+
// the incoming side ramps while the outgoing side stays at 1. That is
|
|
622
|
+
// the compositing model, not an oversight.
|
|
623
|
+
const factor = crossfadeFactor(item)
|
|
624
|
+
const g = item.type === 'overlay'
|
|
546
625
|
? animated
|
|
547
|
-
: { ...animated, opacity: geometryFor(item, item.type).opacity }
|
|
626
|
+
: { ...animated, opacity: geometryFor(item, item.type).opacity * factor }
|
|
548
627
|
const fit = g.fit ?? 'cover'
|
|
549
628
|
const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
|
|
550
629
|
const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
|
|
@@ -12,7 +12,7 @@ import OverlayItemsLayer from './OverlayItemsLayer'
|
|
|
12
12
|
import { useVideoPlayback } from './useVideoPlayback'
|
|
13
13
|
import { useEnginePlayback, type EnginePlayback } from './useEnginePlayback'
|
|
14
14
|
import EngineSurface from './EngineSurface'
|
|
15
|
-
import { evaluateEngineEligibility } from '../../engine/eligibility'
|
|
15
|
+
import { evaluateEngineEligibility, engineRequiredReason } from '../../engine/eligibility'
|
|
16
16
|
import type { AcquiredDemux } from '../../engine'
|
|
17
17
|
import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
|
|
18
18
|
import { gateTimeSink, handOverToHover, useHoverScrubTime, type HoverScrub } from '../hover-scrub'
|
|
@@ -630,6 +630,33 @@ function PreviewSurface({
|
|
|
630
630
|
internal ordering). No-ops on an absent/unrecognized platform, so
|
|
631
631
|
this is unconditional. */}
|
|
632
632
|
<SocialSafeZoneOverlay platform={socialPreview} />
|
|
633
|
+
|
|
634
|
+
{/* Task 10b — legacy has no compositing stage (one <video> element per
|
|
635
|
+
clip), so it cannot blend a clip crossfade the way render does. v4's
|
|
636
|
+
rule is against a preview that SILENTLY disagrees with the export;
|
|
637
|
+
a visible, persistent notice satisfies that without blocking
|
|
638
|
+
preview outright, which would make a whole project un-previewable
|
|
639
|
+
over one transition (and would make every background-removed
|
|
640
|
+
project, legacy-only in v1, never previewable at all — see
|
|
641
|
+
`engineRequiredReason`'s doc in eligibility.ts).
|
|
642
|
+
|
|
643
|
+
Deliberately NOT folded into `useEngineMode`/`mode` above: that hook
|
|
644
|
+
evaluates once per project LOAD, on purpose, so the PLAYER never
|
|
645
|
+
remounts mid-edit (the anti-flapping rule — see its comment). This
|
|
646
|
+
check is the opposite by design: cheap, synchronous, recomputed on
|
|
647
|
+
every render, so the banner appears the instant an operator drags
|
|
648
|
+
two clips into overlap and disappears the instant they pull them
|
|
649
|
+
apart, without ever touching which player is mounted. */}
|
|
650
|
+
{playback.mode === 'legacy' && engineRequiredReason(project) !== null && (
|
|
651
|
+
<div
|
|
652
|
+
className="montaj-legacy-crossfade-banner absolute inset-x-0 bottom-0 flex justify-center pointer-events-none"
|
|
653
|
+
style={{ zIndex: 90 }}
|
|
654
|
+
>
|
|
655
|
+
<div className="mb-2 rounded bg-black/70 px-3 py-1.5 text-center text-xs text-white/90">
|
|
656
|
+
Crossfades will not appear in this preview. They will render in the export.
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
)}
|
|
633
660
|
</div>
|
|
634
661
|
)
|
|
635
662
|
}
|
|
@@ -361,3 +361,94 @@ describe('OverlayItemsLayer — per-axis scale', () => {
|
|
|
361
361
|
expect(nw.style.transform).toBe('scale(0.5, 0.5) translate(-50%, -50%)')
|
|
362
362
|
})
|
|
363
363
|
})
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Layered-clip transition blend (Task 11). SP9d's pin — a clip's opacity
|
|
367
|
+
// CURVE is ignored in this layer — still holds; it is not what this section
|
|
368
|
+
// tests. What's new is the resolver's `crossfade` stamp: two clips on the
|
|
369
|
+
// SAME overlay track that overlap now blend, because the export genuinely
|
|
370
|
+
// blends that pair (encode-segment.js's `blend` branch, SP9d/T8). Showing the
|
|
371
|
+
// same blend here is an application of the SP9d rule, not an exception to it.
|
|
372
|
+
//
|
|
373
|
+
// Unlike `renderLayer` above (which renders its item from the `overlayTracks`
|
|
374
|
+
// prop while `project.tracks` stays empty), these tests need `project.tracks`
|
|
375
|
+
// itself to carry the items: the component looks its resolved crossfade stamp
|
|
376
|
+
// up via `resolveAt(project, ...)`, matched back to the rendered item BY
|
|
377
|
+
// REFERENCE — so the fixture's `project.tracks` and its `overlayTracks` prop
|
|
378
|
+
// must share the exact same item objects.
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
function renderLayeredClips(overlayItems: VisualItem[], currentTime: number) {
|
|
382
|
+
const project = {
|
|
383
|
+
id: 'p',
|
|
384
|
+
status: 'draft',
|
|
385
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
386
|
+
tracks: [[], overlayItems],
|
|
387
|
+
} as unknown as EditorProject
|
|
388
|
+
|
|
389
|
+
const utils = render(
|
|
390
|
+
<OverlayItemsLayer
|
|
391
|
+
project={project}
|
|
392
|
+
currentTime={currentTime}
|
|
393
|
+
isPlaying={false}
|
|
394
|
+
isCanvasProject={false}
|
|
395
|
+
overlayTracks={[overlayItems]}
|
|
396
|
+
tracks0NonVideo={[]}
|
|
397
|
+
renderScale={0.2}
|
|
398
|
+
selectedOverlayId={undefined}
|
|
399
|
+
containerRef={{ current: document.createElement('div') }}
|
|
400
|
+
dragState={null}
|
|
401
|
+
setDragState={vi.fn()}
|
|
402
|
+
liveOffset={null}
|
|
403
|
+
liveScale={null}
|
|
404
|
+
liveRotation={null}
|
|
405
|
+
snapGuides={emptySnap}
|
|
406
|
+
snapRotation={null}
|
|
407
|
+
compileOverlay={vi.fn(async (): Promise<OverlayFactory> => () => null)}
|
|
408
|
+
fileUrl={(pth: string) => pth}
|
|
409
|
+
/>,
|
|
410
|
+
)
|
|
411
|
+
// Each clip's <img> sits directly inside its own wrapper div — the wrapper
|
|
412
|
+
// is where `opacity` actually lands (OverlayItemsLayer.tsx's `wrapperStyle`).
|
|
413
|
+
const styleOf = (src: string) =>
|
|
414
|
+
(utils.container.querySelector(`img[src="${src}"]`) as HTMLElement).parentElement!.style
|
|
415
|
+
return { ...utils, styleOf }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
describe('OverlayItemsLayer — layered clip transition blend (Task 11)', () => {
|
|
419
|
+
it('a layered clip mid-transition renders at its blended opacity', () => {
|
|
420
|
+
// a: [0,4), b: [3,7) — overlap [3,4). At t=3.5 the pair is exactly
|
|
421
|
+
// half-through: p=0.5.
|
|
422
|
+
//
|
|
423
|
+
// ONLY THE INCOMING SIDE RAMPS. `a` (role 'from') stays at 1 and `b` (role
|
|
424
|
+
// 'to') gets 0.5, because this layer stacks DOM nodes with source-over: `b`
|
|
425
|
+
// at alpha 0.5 over an opaque `a` composites to `0.5*b + 0.5*a`, which is
|
|
426
|
+
// exactly the lerp `encode-segment.js` emits as `blend=all_expr='A+(B-A)*p'`.
|
|
427
|
+
//
|
|
428
|
+
// The old expectation was '0.5' / '0.5', and it was WRONG: symmetric factors
|
|
429
|
+
// in a sequential stack give `p*b + (1-p)^2*a + p(1-p)*bg` — `a` at quarter
|
|
430
|
+
// weight with the background leaking through mid-transition. It passed only
|
|
431
|
+
// because it was written against the same mistaken model as the code.
|
|
432
|
+
// `createCanvasPainter.paintBlend` (engine/index.ts) is the reference.
|
|
433
|
+
const a = { id: 'a', type: 'image', src: 'a.png', start: 0, end: 4 } as VisualItem
|
|
434
|
+
const b = { id: 'b', type: 'image', src: 'b.png', start: 3, end: 7 } as VisualItem
|
|
435
|
+
const { styleOf } = renderLayeredClips([a, b], 3.5)
|
|
436
|
+
|
|
437
|
+
expect(styleOf('a.png').opacity).toBe('1')
|
|
438
|
+
expect(styleOf('b.png').opacity).toBe('0.5')
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('a layered clip outside a transition still reads its STATIC opacity', () => {
|
|
442
|
+
// A lone clip on its track has no partner to pair with, so the resolver
|
|
443
|
+
// stamps `crossfade: null` — this is the SP9d regression guard: the
|
|
444
|
+
// opacity keyframe curve must stay ignored, and the wrapper must show the
|
|
445
|
+
// STATIC `opacity` field (0.4), not a sampled curve value.
|
|
446
|
+
const item = {
|
|
447
|
+
id: 'a', type: 'image', src: 'a.png', start: 0, end: 10, opacity: 0.4,
|
|
448
|
+
keyframes: [{ prop: 'opacity', points: [{ t: 0, value: 0 }, { t: 10, value: 1 }] }],
|
|
449
|
+
} as VisualItem
|
|
450
|
+
const { styleOf } = renderLayeredClips([item], 1)
|
|
451
|
+
|
|
452
|
+
expect(styleOf('a.png').opacity).toBe('0.4')
|
|
453
|
+
})
|
|
454
|
+
})
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* here is which surface mounts, not what it paints.
|
|
16
16
|
*/
|
|
17
17
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
18
|
-
import { render, waitFor } from '@testing-library/react'
|
|
18
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
19
19
|
import PreviewPlayer from '../PreviewPlayer'
|
|
20
20
|
import { createPlaybackClock } from '../../playback-clock'
|
|
21
21
|
import { __setEngineCapabilityForTests } from '../../../engine/eligibility'
|
|
@@ -48,6 +48,27 @@ function makeProject(clipOverrides: Record<string, unknown> = {}): Project {
|
|
|
48
48
|
} as unknown as Project
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Two overlapping track-0 video clips — c1 starts (3) before c0 ends (5) and
|
|
53
|
+
* does not contain it, a real `transitionPairs()` pair per
|
|
54
|
+
* `engineRequiredReason`'s test in eligibility.test.ts. `withProxy` controls
|
|
55
|
+
* whether the pair is also engine-shape-eligible, so the same fixture serves
|
|
56
|
+
* both the "still building proxies" legacy case and the "engine running"
|
|
57
|
+
* case.
|
|
58
|
+
*/
|
|
59
|
+
function makeCrossfadeProject(withProxy: boolean): Project {
|
|
60
|
+
const proxy = withProxy ? { proxySrc: 'proxy.mp4' } : {}
|
|
61
|
+
return {
|
|
62
|
+
id: withProxy ? 'p-crossfade-proxy' : 'p-crossfade-no-proxy',
|
|
63
|
+
status: 'draft',
|
|
64
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
65
|
+
tracks: [[
|
|
66
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, ...proxy },
|
|
67
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 3, end: 8, ...proxy },
|
|
68
|
+
]],
|
|
69
|
+
} as unknown as Project
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
function renderPreview(project: Project, engine?: { enabled: boolean }) {
|
|
52
73
|
return render(
|
|
53
74
|
<PreviewPlayer
|
|
@@ -137,3 +158,30 @@ describe('PreviewPlayer engine branch', () => {
|
|
|
137
158
|
expect(root.querySelectorAll('svg')).toHaveLength(1)
|
|
138
159
|
})
|
|
139
160
|
})
|
|
161
|
+
|
|
162
|
+
describe('PreviewPlayer legacy crossfade banner (Task 10b)', () => {
|
|
163
|
+
it('warns that crossfades are missing when a crossfading project falls back to legacy', async () => {
|
|
164
|
+
// No proxySrc => shape-ineligible => legacy player, which cannot blend.
|
|
165
|
+
renderPreview(makeCrossfadeProject(false))
|
|
166
|
+
await screen.findByText(/crossfades will not appear/i)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('shows no such warning when the engine is running', async () => {
|
|
170
|
+
__setEngineCapabilityForTests(true)
|
|
171
|
+
renderPreview(makeCrossfadeProject(true), { enabled: true })
|
|
172
|
+
|
|
173
|
+
// Wait for the engine to actually take over (canvas mounted, legacy
|
|
174
|
+
// <video> slots gone) before asserting the banner's absence — otherwise
|
|
175
|
+
// the assertion could pass trivially on the pre-decision render.
|
|
176
|
+
await waitFor(() => expect(document.querySelector('canvas')).not.toBeNull())
|
|
177
|
+
expect(screen.queryByText(/crossfades will not appear/i)).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('shows no such warning on a legacy project that has no crossfade', () => {
|
|
181
|
+
// A single track-0 clip: engineRequiredReason is trivially null
|
|
182
|
+
// (transitionPairs needs 2+ items), and the flag is absent so this is
|
|
183
|
+
// legacy synchronously, same as the "flag absent" test above.
|
|
184
|
+
renderPreview(makeProject())
|
|
185
|
+
expect(screen.queryByText(/crossfades will not appear/i)).toBeNull()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas rAF clock's CEILING, driven through the real effect.
|
|
3
|
+
*
|
|
4
|
+
* `canvasMaxEndRef` is the only thing standing between "space plays" and "space
|
|
5
|
+
* does nothing" on a project with no track-0 video. It used to read
|
|
6
|
+
* `overlayTracks` (`tracks.slice(1)`), which silently skips track 0 — and track
|
|
7
|
+
* 0 in canvas mode is not the primary footage track, it is content: the
|
|
8
|
+
* background images, and on an agent-authored project frequently the overlays
|
|
9
|
+
* themselves. A project that is ONE track of nothing but overlays (what the
|
|
10
|
+
* animations workflow emits) therefore got a ceiling of 0, and the first tick
|
|
11
|
+
* clamped the playhead to 0 and called `setIsPlaying(false)` in the same frame.
|
|
12
|
+
*
|
|
13
|
+
* These drive the hook's own rAF effect rather than asserting on a helper: the
|
|
14
|
+
* ceiling lives in a ref that nothing exports, so the only honest way to pin it
|
|
15
|
+
* is to run the clock and watch where time goes. Each case fails on the
|
|
16
|
+
* pre-fix `overlayTracks` read.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
19
|
+
import { act, renderHook } from '@testing-library/react'
|
|
20
|
+
import { useVideoPlayback } from '../useVideoPlayback'
|
|
21
|
+
import type { EditorProject, VisualItem } from '../../../schema'
|
|
22
|
+
|
|
23
|
+
const overlay = (id: string, start: number, end: number): VisualItem =>
|
|
24
|
+
({ id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end }) as VisualItem
|
|
25
|
+
|
|
26
|
+
const image = (id: string, start: number, end: number): VisualItem =>
|
|
27
|
+
({ id, type: 'image', src: `/img/${id}.png`, start, end }) as VisualItem
|
|
28
|
+
|
|
29
|
+
/** The last emission. Not `.at(-1)` — this package targets ES2020. */
|
|
30
|
+
const last = (xs: number[]): number | undefined => xs[xs.length - 1]
|
|
31
|
+
|
|
32
|
+
function projectOf(...tracks: VisualItem[][]): EditorProject {
|
|
33
|
+
return {
|
|
34
|
+
id: 'canvas-clock',
|
|
35
|
+
status: 'draft',
|
|
36
|
+
settings: { resolution: [1920, 1080] },
|
|
37
|
+
tracks: tracks.map((items, i) => ({ id: `trk-${i}`, items })),
|
|
38
|
+
} as EditorProject
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mount the hook on a canvas project and run the clock for one second of wall
|
|
43
|
+
* time, returning every playhead value it emitted.
|
|
44
|
+
*
|
|
45
|
+
* Two ticks, because the first only anchors `rafLastMs` — it has no `dt` yet
|
|
46
|
+
* and emits nothing. The second is the first that can move the playhead, and
|
|
47
|
+
* the one the collapsed ceiling used to kill.
|
|
48
|
+
*/
|
|
49
|
+
function runClock(project: EditorProject) {
|
|
50
|
+
const times: number[] = []
|
|
51
|
+
const scheduled: FrameRequestCallback[] = []
|
|
52
|
+
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
|
|
53
|
+
scheduled.push(cb)
|
|
54
|
+
return scheduled.length
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const view = renderHook(() => useVideoPlayback(project, 0, (t) => times.push(t), (p) => p))
|
|
58
|
+
expect(view.result.current.isCanvasProject).toBe(true)
|
|
59
|
+
|
|
60
|
+
scheduled.length = 0
|
|
61
|
+
act(() => { view.result.current.setIsPlaying(true) })
|
|
62
|
+
expect(scheduled.length).toBeGreaterThan(0)
|
|
63
|
+
|
|
64
|
+
act(() => { scheduled[0](0) }) // anchor only
|
|
65
|
+
act(() => { scheduled[scheduled.length - 1](1000) }) // +1s of wall clock
|
|
66
|
+
|
|
67
|
+
return { times, isPlaying: view.result.current.isPlaying }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('useVideoPlayback — the canvas clock counts track 0', () => {
|
|
71
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
72
|
+
|
|
73
|
+
it('advances on a single track holding nothing but overlays', () => {
|
|
74
|
+
// The Daubert-demo shape: one track, 14 overlays, no captions, no video.
|
|
75
|
+
const { times, isPlaying } = runClock(projectOf([overlay('o1', 0, 5), overlay('o2', 5, 12)]))
|
|
76
|
+
expect(last(times)).toBeCloseTo(1, 6)
|
|
77
|
+
expect(isPlaying).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('advances on a track-0 image that outlasts every overlay track', () => {
|
|
81
|
+
// Same defect, other content kind — the background image was invisible to
|
|
82
|
+
// the ceiling, so a slideshow stopped at its last overlay.
|
|
83
|
+
//
|
|
84
|
+
// The overlay track has to end INSIDE the second this clock runs (0.4 < 1),
|
|
85
|
+
// or `overlayTracks` alone already carries the playhead to 1 and the case
|
|
86
|
+
// passes on the broken code without ever reading track 0.
|
|
87
|
+
const { times } = runClock(projectOf([image('bg', 0, 20)], [overlay('o', 0, 0.4)]))
|
|
88
|
+
expect(last(times)).toBeCloseTo(1, 6)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('still stops at the ceiling rather than running past the content', () => {
|
|
92
|
+
// The clamp itself is not what was wrong, and must survive: one second of
|
|
93
|
+
// wall clock against a half-second project lands on the end, not past it,
|
|
94
|
+
// and playback stops there.
|
|
95
|
+
const { times, isPlaying } = runClock(projectOf([overlay('o', 0, 0.5)]))
|
|
96
|
+
expect(last(times)).toBeCloseTo(0.5, 6)
|
|
97
|
+
expect(isPlaying).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -322,18 +322,33 @@ export function useVideoPlayback(
|
|
|
322
322
|
applyClipVolume(clip)
|
|
323
323
|
}, [clips, videoTrack, activeSlot])
|
|
324
324
|
|
|
325
|
-
// maxEnd for the canvas rAF clock — the furthest
|
|
325
|
+
// maxEnd for the canvas rAF clock — the furthest visual/caption end. Kept in
|
|
326
326
|
// a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
|
|
327
327
|
// down and rebuild on every project spread (only isPlaying/onTimeUpdate matter
|
|
328
328
|
// to it). onTimeUpdate is the stable clock.set identity.
|
|
329
|
+
//
|
|
330
|
+
// Spans EVERY enabled track, track 0 INCLUDED — not `overlayTracks`
|
|
331
|
+
// (`slice(1)`). This clock only ever runs for canvas projects, where track 0
|
|
332
|
+
// holds content rather than the primary footage: the background images, and
|
|
333
|
+
// on an agent-authored project frequently the overlays themselves (an
|
|
334
|
+
// animations-workflow project is often ONE track of nothing but overlays).
|
|
335
|
+
// Reading `overlayTracks` here collapsed the ceiling to 0 for exactly those,
|
|
336
|
+
// so the very first tick clamped to 0 and immediately called
|
|
337
|
+
// `setIsPlaying(false)` — space appeared to do nothing at all.
|
|
338
|
+
// `OverlayItemsLayer` already draws track 0 in canvas mode; this keeps the
|
|
339
|
+
// clock's ceiling and what's on screen in agreement. Mirrored in the engine
|
|
340
|
+
// path's `transportEndFor` (engine/scheduler.ts) — change both together.
|
|
341
|
+
//
|
|
342
|
+
// Audio stays OUT, unchanged: the canvas/video divergence over the audio tail
|
|
343
|
+
// is documented in timeline-core's `durations.js` and is not this fix.
|
|
329
344
|
const canvasMaxEndRef = useRef(0)
|
|
330
345
|
useEffect(() => {
|
|
331
346
|
const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
|
|
332
347
|
canvasMaxEndRef.current = Math.max(
|
|
333
|
-
|
|
348
|
+
enabledTrackItems(project).flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
|
|
334
349
|
captionEnd,
|
|
335
350
|
)
|
|
336
|
-
}, [
|
|
351
|
+
}, [project])
|
|
337
352
|
|
|
338
353
|
useEffect(() => {
|
|
339
354
|
if (!isCanvasProject) return
|
|
@@ -18,7 +18,8 @@ import { VISUAL_EDGE_TOLERANCE_PX } from './canvas/hit-test'
|
|
|
18
18
|
import { keyframeUnionTimes } from './canvas/keyframe-strip'
|
|
19
19
|
import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline-model'
|
|
20
20
|
import { deleteSelection, toggleSelection } from './multiSelectOps'
|
|
21
|
-
import {
|
|
21
|
+
import { removeMarkers } from './markers'
|
|
22
|
+
import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, trackItems } from './timeline-model'
|
|
22
23
|
import TimelineCanvas, { useCanvasZoomControls, type ZoomControls } from './canvas/TimelineCanvas'
|
|
23
24
|
import type { KeyframeSelection } from './canvas/pointer-machine'
|
|
24
25
|
import { timeToX, useViewportStore, useViewportValue, xToTime, type ViewportStore } from './canvas/viewport'
|
|
@@ -657,6 +658,48 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
|
|
|
657
658
|
// re-runs on real edits (see above for why the fades belong in the key).
|
|
658
659
|
}, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}:${t.fadeIn ?? ''}:${t.fadeOut ?? ''}`).join('|')])
|
|
659
660
|
|
|
661
|
+
// The VISUAL sibling of the pass above: two OVERLAYS overlapping on the same
|
|
662
|
+
// track get complementary derived `opacity` curves (`computeVisualCrossfade`
|
|
663
|
+
// in timeline-model.ts, so the decision logic is shared and testable away
|
|
664
|
+
// from React).
|
|
665
|
+
//
|
|
666
|
+
// A separate effect, deliberately NOT folded into the one above. That one's
|
|
667
|
+
// dependency digest is keyed on the audio FADES on purpose — the digest is
|
|
668
|
+
// what clears a pending timer instead of letting a stale one fire — and
|
|
669
|
+
// mixing a second, independently-changing key into it would re-arm the audio
|
|
670
|
+
// timer on every unrelated overlay edit, and the visual timer on every
|
|
671
|
+
// unrelated audio edit. Two passes, two digests, two timers.
|
|
672
|
+
//
|
|
673
|
+
// Same debounce, for the same reason: an overlay's span moves on every
|
|
674
|
+
// mousemove of a drag, and committing each one is the per-move-undo bug. A
|
|
675
|
+
// gesture's own commit (`commitTimelineEdit`) folds the visual fade into its
|
|
676
|
+
// one undo step, so mid-drag this timer is cleared and rescheduled every
|
|
677
|
+
// frame and never fires, and right after a gesture it no-ops because
|
|
678
|
+
// `computeVisualCrossfade` is idempotent. This pass is therefore only the
|
|
679
|
+
// catch-all for overlay timing that changes OUTSIDE a gesture — ripple-
|
|
680
|
+
// delete, gap-collapse — which reach `sync.mutate` directly and never pass
|
|
681
|
+
// through `commitTimelineEdit` at all. Commits only; a preview via
|
|
682
|
+
// `onProjectChange` would change the digest below and clear the timer before
|
|
683
|
+
// it fired, so a ripple-delete's fade would never get saved.
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
if (!onOverlayEdit) return
|
|
686
|
+
const next = computeVisualCrossfade(project)
|
|
687
|
+
if (!next) return
|
|
688
|
+
const timer = setTimeout(() => onOverlayEdit(next), CROSSFADE_COMMIT_DELAY_MS)
|
|
689
|
+
return () => clearTimeout(timer)
|
|
690
|
+
// Keyed on every visual item's SPAN and its DERIVED opacity curve, for
|
|
691
|
+
// exactly the reason the audio digest above includes the fades: the
|
|
692
|
+
// gesture's own crossfade commit changes this digest, which re-runs the
|
|
693
|
+
// effect and clears the pending timer rather than letting a stale one fire.
|
|
694
|
+
// `origin` is what makes a curve derived, and this pass is its only writer,
|
|
695
|
+
// so a hand-authored curve (no `origin`) contributes nothing to the key —
|
|
696
|
+
// it can never be the reason this pass needs to run again.
|
|
697
|
+
}, [allTracks.map(items => items.map(i => {
|
|
698
|
+
const fade = i.keyframes?.find(k => k.prop === 'opacity')
|
|
699
|
+
const curve = fade?.origin ? fade.points.map(p => `${p.t},${p.value}`).join(';') : ''
|
|
700
|
+
return `${i.id}:${i.start}:${i.end}:${curve}`
|
|
701
|
+
}).join('|')).join('||')])
|
|
702
|
+
|
|
660
703
|
// The tabIndex={0} root below — Delete/Enter's guards below check focus is
|
|
661
704
|
// inside it before firing (see the useKeymap block), restoring the pre-T9
|
|
662
705
|
// scoping those two bindings had (arrows/Escape were already document-level
|
|
@@ -794,6 +837,11 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
|
|
|
794
837
|
updated = { ...updated, captions: normalizeCaptionLanes({ ...captionTrack, segments: kept }) }
|
|
795
838
|
}
|
|
796
839
|
}
|
|
840
|
+
// Markers live at project.markers, outside tracks/audio, so `deleteSelection`
|
|
841
|
+
// cannot see them — same reason captions need their own strip above. Folded
|
|
842
|
+
// into the SAME commit so a mixed clip + caption + marker delete is one
|
|
843
|
+
// undo entry.
|
|
844
|
+
updated = removeMarkers(updated, new Set(selectedIds))
|
|
797
845
|
if (rippleMode) updated = collapseGaps(updated)
|
|
798
846
|
// Deleting a clip out of a magnetic audio lane leaves a gap exactly
|
|
799
847
|
// like a trim or a move would — close it the same way, on release.
|
|
@@ -360,6 +360,22 @@ describe('Timeline — T9 keymap (arrows / delete / enter / escape)', () => {
|
|
|
360
360
|
expect(onProjectChange).not.toHaveBeenCalled()
|
|
361
361
|
})
|
|
362
362
|
|
|
363
|
+
it('Delete removes a selected marker, in the same commit as everything else', () => {
|
|
364
|
+
// Markers live outside tracks/audio, so deleteSelection cannot see them —
|
|
365
|
+
// the same reason captions needed their own strip in this action.
|
|
366
|
+
const clock = createPlaybackClock(0)
|
|
367
|
+
const onProjectChange = vi.fn()
|
|
368
|
+
const project = { ...makeProject(), markers: [{ id: 'm1', t: 5, label: '1' }] }
|
|
369
|
+
const { container } = render(
|
|
370
|
+
<Timeline project={project} clock={clock} selectedIds={['m1']} onProjectChange={onProjectChange} />,
|
|
371
|
+
)
|
|
372
|
+
focusTimelineRoot(container)
|
|
373
|
+
fireEvent.keyDown(document.body, { key: 'Delete' })
|
|
374
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
375
|
+
const updated = onProjectChange.mock.calls[0][0] as Project
|
|
376
|
+
expect(updated.markers).toBeUndefined() // last one gone → key dropped
|
|
377
|
+
})
|
|
378
|
+
|
|
363
379
|
it('exposes zoomFit through actionsRef for a host-level palette', () => {
|
|
364
380
|
const clock = createPlaybackClock(1)
|
|
365
381
|
const actionsRef: { current: TimelineActions | null } = { current: null }
|