Compare commits

...

4 Commits

Author SHA1 Message Date
dsehnal
fdc33e44dc 5.0.0-dev.10 2025-08-26 17:43:06 +02:00
David Sehnal
b0aa889a0a MVS: Animation improvements (#1631)
* allow interpolation "keyframing"

* animation fixes
2025-08-26 17:41:21 +02:00
David Sehnal
4d7bd53231 Additional markdown commands (#1630) 2025-08-26 06:58:40 +02:00
David Sehnal
c11cf665c9 Additional markdown extensions (#1629)
* additional markdown extensions

* fixes
2025-08-25 20:12:10 +02:00
14 changed files with 347 additions and 123 deletions

View File

@@ -13,6 +13,7 @@ Note that since we don't clearly distinguish between a public and private interf
- This change is breaking because all volume objects require the `instances` field now.
- [Breaking] `Canvas3D.identify` now expects `Vec2` or `Ray3D`
- [Breaking] `TrackballControlsParams.animate.spin.speed` now means "Number of rotations per second" instead of "radians per second"
- [Breaking] `PluginStateSnapshotManager.play` now accepts an options object instead of a single boolean value
- Update production build to use `esbuild`
- Emit explicit paths in `import`s in `lib/`
- Fix outlines on opaque elements using illumination mode

View File

@@ -20,6 +20,10 @@ Generally, the command should be URL encoded, e.g., `a b` => `a%20b` (in JS, `en
- `center-camera` - Centers the camera
- `apply-snapshot=key` - Loads snapshots with the provided key
- `next-snapshot[=-1|1]` - Loads next/previous snapshot, the direction is optional and default to `1`
- `play-snapshots` - Starts playback of state snapshots
- `play-transition` - Plays an animation associated with the given snapshot
- `stop-animation` - Stops currently playing animation
- `focus-refs=ref1,ref2,...` - On click, focuses nodes with the provided refs
- `highlight-refs=ref1,ref2,...` - On mouse over, highlights the provided refs
- `query=...&lang=...&action=highlight,focus&focus-radius=...`
@@ -28,7 +32,7 @@ Generally, the command should be URL encoded, e.g., `a b` => `a%20b` (in JS, `en
- (optional) `action` is an array of `highlight` (default), `focus` (multiple actions can be specified)
- (optional) `focus-radius` is extra distance applied when focusing the selection (default is `3`)
- Example: `[HEM](!query=resn%20HEM%26lang=pymol&action=highlight,focus)` highlights or focuses the HEM residue (the query must be URL encoded because it contains spaces and possibly other special characters)
- `play-audio=src`, `toggle-audio[=str]`, `stop-audio`, `pause-audio` - Audio playback support
- `play-audio=src`, `toggle-audio[=src]`, `stop-audio`, `pause-audio`, `dispose-audio` - Audio playback support
## Custom Content

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "molstar",
"version": "5.0.0-dev.9",
"version": "5.0.0-dev.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "molstar",
"version": "5.0.0-dev.9",
"version": "5.0.0-dev.10",
"license": "MIT",
"dependencies": {
"@types/argparse": "^2.0.17",

View File

@@ -1,6 +1,6 @@
{
"name": "molstar",
"version": "5.0.0-dev.9",
"version": "5.0.0-dev.10",
"description": "A comprehensive macromolecular library.",
"homepage": "https://github.com/molstar/molstar#readme",
"repository": {

View File

@@ -182,6 +182,15 @@
max-width: 100%;
height: auto;
}
a {
text-decoration: none;
color: #1d4ed7;
&:hover {
text-decoration: underline;
}
}
}
@media (orientation:portrait) {

View File

@@ -22,7 +22,15 @@ const Steps = [
header: 'Animation Demo',
key: 'intro',
description: `### Molecular Animation
A story showcasing MolViewSpec animation capabilities.`,
A story showcasing MolViewSpec animation capabilities.
[\[**🔄 Replay Intro**\]](!play-transition)
[\[**⏵ Play Snapshots**\]](!play-snapshots)
[\[**⏹ Stop Animation**\]](!stop-animation)
[\[**➡️ Next Snapshot**\]](!next-snapshot)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,
state: (): Root => {
@@ -41,7 +49,7 @@ A story showcasing MolViewSpec animation capabilities.`,
custom: {
molstar_trackball: {
name: 'rock',
params: { speed: 1.5 },
params: { speed: 0.5 },
}
}
});
@@ -49,11 +57,23 @@ A story showcasing MolViewSpec animation capabilities.`,
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
duration_ms: 1000,
start_ms: 500,
duration_ms: 500,
property: 'label_opacity',
end: 1,
});
anim.interpolate({
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
start_ms: 1500,
duration_ms: 500,
property: 'label_opacity',
start: 1,
end: 0.66,
});
// Uncomment this to make 2nd frame render much faster
// It will cause shader compilation to happen during the 1st snapshot
@@ -119,14 +139,32 @@ A story showcasing MolViewSpec animation capabilities.`,
anim.interpolate({
kind: 'scalar',
ref: 'clip-transition',
target_ref: 'clip',
duration_ms: 2000,
duration_ms: 500,
property: ['point', 2],
end: 55,
easing: 'sin-in',
});
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
start_ms: 600,
duration_ms: 800,
property: ['point', 2],
end: 0,
easing: 'sin-out',
});
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
start_ms: 1500,
duration_ms: 500,
property: ['point', 2],
end: 55,
});
anim.interpolate({
kind: 'vec3',
target_ref: 'xform',

View File

@@ -22,15 +22,16 @@ const Steps = [
{
header: 'Audio Demo',
key: 'intro',
description: `### Audio Playback
description: `
**Audio**
[ **▶ Play** ](${encodeURIComponent(`!play-audio=${_Audio}`)})
[ **⏸ Pause** ](!pause-audio)
[ **⏹ Stop** ](!stop-audio)
[ **Hide** ](!dispose-audio)
### Audio Playback
A simple example showcasing audio playback.
Basic controls:
[Play](${encodeURIComponent(`!play-audio=${_Audio}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,
@@ -50,7 +51,7 @@ Basic controls:
}
}
});
prims.label({ text: 'Click to Play', position: { label_asym_id: 'A' }, label_size: 10 });
prims.label({ text: 'Click to Play', position: { label_asym_id: 'A' }, label_size: 10 });
return builder;
},
@@ -67,7 +68,7 @@ Basic controls:
If you clicked "Click to Play" in the previous snapshot, the audio should be playing now.
[Stop](!stop-audio)
[Stop Audio](!stop-audio)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,
@@ -103,7 +104,7 @@ If you clicked "Click to Play" in the previous snapshot, the audio should be pla
If you browser security permissions allow it, the audio should start playing automatically when the snapshot gets loaded
[Stop](!stop-audio)
[Stop Audio](!stop-audio)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,

View File

@@ -5,21 +5,22 @@
* @author Ludovic Autin <ludovic.autin@gmail.com>
*/
import { Snapshot } from '../mvs-data';
import { Tree } from '../tree/generic/tree-schema';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { MVSAnimationEasing, MVSAnimationNode, MVSAnimationSchema } from '../tree/animation/animation-tree';
import { MVSTree } from '../tree/mvs/mvs-tree';
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
import { addDefaults } from '../tree/generic/tree-utils';
import { RuntimeContext } from '../../../mol-task';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { EPSILON, Mat3, Mat4, Quat, Vec3 } from '../../../mol-math/linear-algebra';
import { RuntimeContext } from '../../../mol-task';
import { deepEqual } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { produce } from '../../../mol-util/produce';
import { makeContinuousPaletteCheckpoints, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from '../components/annotation-color-theme';
import { palettePropsFromMVSPalette } from '../load-helpers';
import { SortedArray } from '../../../mol-data/int';
import { Snapshot } from '../mvs-data';
import { MVSAnimationEasing, MVSAnimationNode, MVSAnimationSchema } from '../tree/animation/animation-tree';
import { Tree } from '../tree/generic/tree-schema';
import { addDefaults } from '../tree/generic/tree-utils';
import { MVSTree } from '../tree/mvs/mvs-tree';
import { ColorT } from '../tree/mvs/param-types';
import { produce } from '../../../mol-util/produce';
export async function generateStateTransition(ctx: RuntimeContext, snapshot: Snapshot, snapshotIndex: number, snapshotCount: number) {
if (!snapshot.animation) return undefined;
@@ -33,17 +34,27 @@ export async function generateStateTransition(ctx: RuntimeContext, snapshot: Sna
...transitions.map(t => (t.params.start_ms ?? 0) + t.params.duration_ms)
);
const frames: MVSTree[] = [];
const frames: [tree: MVSTree, time: number][] = [];
const dt = tree.params?.frame_time_ms ?? (1000 / 60);
const N = Math.ceil(duration / dt);
const nodeMap = makeNodeMap(snapshot.root, new Map(), []);
const cache = new Map<any, InterpolationCacheEntry>();
const transitionGroups = groupTranstions(transitions);
let prevRoot: MVSTree | undefined;
for (let i = 0; i <= N; i++) {
const t = i * dt;
const root = createSnapshot(snapshot.root, transitions, t, cache, nodeMap);
frames.push(root);
const root = createSnapshot(snapshot.root, transitionGroups, t, cache, nodeMap);
if (root === prevRoot || (prevRoot && deepEqual(root, prevRoot))) {
frames[frames.length - 1][1] += dt;
} else {
frames.push([root, dt]);
}
prevRoot = root;
if (ctx.shouldUpdate) {
await ctx.update({ message: `Generating transition for snapshot ${snapshotIndex + 1}/${snapshotCount}`, current: i + 1, max: N });
@@ -80,71 +91,91 @@ interface InterpolationCacheEntry {
rotation?: { axis: Vec3, angle: number, start: Quat, end: Quat },
}
function createSnapshot(tree: MVSTree, transitions: MVSAnimationNode<'interpolate'>[], time: number, cache: Map<any, InterpolationCacheEntry>, nodeMap: Map<string, (string | number)[]>) {
return produce(tree, (draft) => {
for (const transition of transitions) {
const nodePath = nodeMap.get(transition.params.target_ref);
function getTransitionKey(transition: MVSAnimationNode<'interpolate'>) {
const prop = transition.params.property;
if (Array.isArray(prop)) {
return `${transition.params.target_ref}:${prop.join('.')}`;
}
return `${transition.params.target_ref}:${prop}`;
}
function groupTranstions(transitions: MVSAnimationNode<'interpolate'>[]) {
const map = new Map<string, MVSAnimationNode<'interpolate'>[]>();
const groups: MVSAnimationNode<'interpolate'>[][] = [];
for (const t of transitions) {
const key = getTransitionKey(t);
if (!map.has(key)) {
const group: MVSAnimationNode<'interpolate'>[] = [];
map.set(key, group);
groups.push(group);
}
map.get(key)!.push(t);
}
for (const group of groups) {
group.sort((a, b) => {
const s = (a.params.start_ms ?? 0) - (b.params.start_ms ?? 0);
if (s !== 0) return s;
return a.params.duration_ms - b.params.duration_ms;
});
}
return groups;
}
function createSnapshot(tree: MVSTree, transitionGroups: MVSAnimationNode<'interpolate'>[][], time: number, cache: Map<any, InterpolationCacheEntry>, nodeMap: Map<string, (string | number)[]>) {
let modified = false;
const ret = produce(tree, (draft) => {
for (const transitionGroup of transitionGroups) {
const pivot = transitionGroup[0];
const nodePath = nodeMap.get(pivot.params.target_ref);
if (!nodePath) continue;
const node = select(draft, nodePath, 0);
const target = transition.params.property[0] === 'custom' ? node?.custom : node?.params;
const target = pivot.params.property[0] === 'custom' ? node?.custom : node?.params;
if (!target) continue;
const offset = pivot.params.property[0] === 'custom' ? 1 : 0;
let transition: MVSAnimationNode<'interpolate'> = pivot;
let previous: MVSAnimationNode<'interpolate'> | undefined;
for (let i = transitionGroup.length - 1; i > 0; i--) {
const current = transitionGroup[i];
const currentStart = current.params.start_ms ?? 0;
if (time >= currentStart) {
transition = current;
previous = i > 0 ? transitionGroup[i - 1] : undefined;
break;
}
}
if (!cache.has(transition)) {
cache.set(transition, {});
}
const cacheEntry: InterpolationCacheEntry = cache.get(transition)!;
const startTime = transition.params.start_ms ?? 0;
let t = clamp((time - startTime) / transition.params.duration_ms, 0, 1);
if (transition.params.kind === 'transform_matrix') {
processTransformMatrix(transition, target, t, cacheEntry);
continue;
}
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const offset = transition.params.property[0] === 'custom' ? 1 : 0;
const startBase = transition.params.start ?? select(target, transition.params.property, offset);
if (transition.params.kind === 'color' && !cacheEntry.paletteFn) {
cacheEntry.paletteFn = makePaletteFunction(transition, startBase, transition.params.end as ColorT | undefined);
}
const paletteFn = cacheEntry.paletteFn!;
const startValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(0))
: startBase;
const endValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(1))
: transition.params.end;
if (time <= startTime) {
assign(target, transition.params.property, startValue, offset);
continue;
}
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
const startTime: number = transition.params.start_ms ?? 0;
const durationMs: number = transition.params.duration_ms ?? 0;
const t = (time - startTime) / durationMs;
let next: any;
if (transition.params.kind === 'scalar') {
next = interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.discrete);
} else if (transition.params.kind === 'vec3') {
next = interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
next = interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
const color = paletteFn(t);
next = Color.toHexStyle(color);
if (transition.params.kind === 'transform_matrix') {
next = processTransformMatrix(transition, target, clamp(t, 0, 1), cacheEntry, offset, previous);
} else {
next = processScalarLike(transition, target, t, cacheEntry, offset, previous);
}
if (next === undefined) {
continue;
}
modified = true;
assign(target, transition.params.property, next, offset);
}
});
return modified ? ret : tree;
}
function applyFrequency(t: number, frequency: number, alternate: boolean) {
@@ -163,6 +194,55 @@ function applyFrequency(t: number, frequency: number, alternate: boolean) {
return v;
}
function getPreviousScalarEnd(previous: MVSAnimationNode<'interpolate'> | undefined) {
if (!previous || previous.params.kind === 'transform_matrix') return undefined;
return previous.params.end;
}
function processScalarLike(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cacheEntry: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind === 'transform_matrix') return;
if (previous && previous.params.kind === 'transform_matrix') return;
const startBase = transition.params.start ?? getPreviousScalarEnd(previous) ?? select(target, transition.params.property, offset);
if (transition.params.kind === 'color' && !cacheEntry.paletteFn) {
cacheEntry.paletteFn = makePaletteFunction(transition, startBase, transition.params.end as ColorT | undefined);
}
const paletteFn = cacheEntry.paletteFn!;
const startValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(0))
: startBase;
const endValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(1))
: transition.params.end;
if (time <= 0) return startValue;
else if (time >= 1 - EPSILON && !transition.params.alternate_direction) return endValue;
let t = clamp(time, 0, 1);
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
if (transition.params.kind === 'scalar') {
return interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.discrete);
} else if (transition.params.kind === 'vec3') {
return interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
return interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
const color = paletteFn(t);
return Color.toHexStyle(color);
}
}
function getPreviousMatrixEnd(previous: MVSAnimationNode<'interpolate'> | undefined, prop: 'rotation_start' | 'translation_start' | 'scale_start') {
if (!previous || previous.params.kind !== 'transform_matrix') return undefined;
return previous.params[prop];
}
const TransformState = {
pivotTranslation: Mat4(),
pivotTranslationInv: Mat4(),
@@ -172,31 +252,41 @@ const TransformState = {
pivotNeg: Vec3(),
temp: Mat4(),
};
function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cache: InterpolationCacheEntry) {
function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cache: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind !== 'transform_matrix') return;
if (previous && previous.params.kind !== 'transform_matrix') return;
const offset = transition.params.property[0] === 'custom' ? 1 : 0;
const transform = select(target, transition.params.property, offset) ?? Mat4.identity();
const startRotation = transition.params.rotation_start ?? Mat3.fromMat4(Mat3(), transform);
const startTranslation = transition.params.translation_start ?? Mat4.getTranslation(Vec3(), transform);
const startScale = transition.params.scale_start ?? Mat4.getScaling(Vec3(), transform);
const startRotation = transition.params.rotation_start ?? getPreviousMatrixEnd(previous, 'rotation_start') ?? Mat3.fromMat4(Mat3(), transform);
const startTranslation = transition.params.translation_start ?? getPreviousMatrixEnd(previous, 'translation_start') ?? Mat4.getTranslation(Vec3(), transform);
const startScale = transition.params.scale_start ?? getPreviousMatrixEnd(previous, 'scale_start') ?? Mat4.getScaling(Vec3(), transform);
const endRotation = transition.params.rotation_end;
const endTranslation = transition.params.translation_end;
const endScale = transition.params.scale_end;
let t = applyFrequency(time, transition.params.rotation_frequency ?? 1, !!transition.params.rotation_alternate_direction);
let easing = EasingFnMap[transition.params.rotation_easing ?? 'linear'] ?? EasingFnMap['linear'];
const rotation = interpolateRotation(startRotation as Mat3, endRotation as Mat3, easing(t), transition.params.rotation_noise_magnitude ?? 0, cache);
let rotation, translation, scale;
t = applyFrequency(time, transition.params.translation_frequency ?? 1, !!transition.params.translation_alternate_direction);
easing = EasingFnMap[transition.params.translation_easing ?? 'linear'] ?? EasingFnMap['linear'];
const translation = interpolateVec3(startTranslation as Vec3, endTranslation as Vec3 | undefined, easing(t), transition.params.translation_noise_magnitude ?? 0, false);
if (time <= 0) {
rotation = startRotation as Mat3;
translation = startTranslation as Vec3;
scale = startScale as Vec3;
} else {
const clampedTime = clamp(time, 0, 1);
t = applyFrequency(time, transition.params.scale_frequency ?? 1, !!transition.params.scale_alternate_direction);
easing = EasingFnMap[transition.params.scale_easing ?? 'linear'] ?? EasingFnMap['linear'];
const scale = interpolateVec3(startScale as Vec3, endScale as Vec3 | undefined, easing(t), transition.params.scale_noise_magnitude ?? 0, false);
let t = applyFrequency(clampedTime, transition.params.rotation_frequency ?? 1, !!transition.params.rotation_alternate_direction);
let easing = EasingFnMap[transition.params.rotation_easing ?? 'linear'] ?? EasingFnMap['linear'];
rotation = interpolateRotation(startRotation as Mat3, endRotation as Mat3, easing(t), transition.params.rotation_noise_magnitude ?? 0, cache);
t = applyFrequency(clampedTime, transition.params.translation_frequency ?? 1, !!transition.params.translation_alternate_direction);
easing = EasingFnMap[transition.params.translation_easing ?? 'linear'] ?? EasingFnMap['linear'];
translation = interpolateVec3(startTranslation as Vec3, endTranslation as Vec3 | undefined, easing(t), transition.params.translation_noise_magnitude ?? 0, false);
t = applyFrequency(clampedTime, transition.params.scale_frequency ?? 1, !!transition.params.scale_alternate_direction);
easing = EasingFnMap[transition.params.scale_easing ?? 'linear'] ?? EasingFnMap['linear'];
scale = interpolateVec3(startScale as Vec3, endScale as Vec3 | undefined, easing(t), transition.params.scale_noise_magnitude ?? 0, false);
}
const pivot = transition.params.pivot ?? Vec3.zero();
@@ -213,7 +303,7 @@ function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, tar
Mat4.mul(result, TransformState.rotation, result);
Mat4.mul(result, TransformState.translation, result);
assign(target, transition.params.property, result, offset);
return result;
}
function interpolateScalars(start: number | number[], end: number | number[] | undefined, t: number, noise: number, discrete: boolean) {

View File

@@ -136,7 +136,7 @@ async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext,
for (let i = 0; i < transitions.frames.length; i++) {
const frame = transitions.frames[i];
const molstarTree = convertMvsToMolstar(frame, options.sourceUrl);
const molstarTree = convertMvsToMolstar(frame[0], options.sourceUrl);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
@@ -148,7 +148,7 @@ async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext,
StateTree.reuseTransformParams(entry.snapshot.data!.tree, parentEntry.snapshot.data!.tree);
animation.frames.push({
durationInMs: transitions.frametimeMs,
durationInMs: frame[1],
data: entry.snapshot.data!,
camera: transitions.tree.params?.include_camera ? entry.snapshot.camera : undefined,
canvas3d: transitions.tree.params?.include_canvas ? entry.snapshot.canvas3d : undefined,

View File

@@ -175,7 +175,7 @@ export const AnimateStateSnapshotTransition = PluginStateAnimation.create({
if (t.current >= animState.totalDuration) {
if (snapshot?.transition && animState.isInitial) {
const frameIndex = snapshot.transition.frames.length - 1;
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(frameIndex, false);
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(animState.totalDuration, false);
await setPartialSnapshot(ctx.plugin, snapshot.transition.frames[frameIndex]);
}
return { kind: 'finished' };
@@ -193,7 +193,7 @@ export const AnimateStateSnapshotTransition = PluginStateAnimation.create({
return { kind: 'skip' };
}
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(frameIndex, false);
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(t.current, false);
if (frameIndex === 0) {
await setPartialSnapshot(ctx.plugin, {
...transition.frames[frameIndex],

View File

@@ -11,6 +11,7 @@ import { PluginContext } from '../../mol-plugin/context';
import { Script } from '../../mol-script/script';
import { QueryContext, QueryFn, StructureElement, StructureSelection } from '../../mol-model/structure';
import { BehaviorSubject } from 'rxjs';
import { AnimateStateSnapshotTransition } from '../animation/built-in/state-snapshots';
export type MarkdownExtensionEvent = 'click' | 'mouse-enter' | 'mouse-leave';
@@ -46,6 +47,17 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
manager.plugin.managers.snapshot.applyKey(key);
}
},
{
name: 'next-snapshot',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('next-snapshot' in args)) return;
let dir: -1 | 1 = (+args['next-snapshot'] || 1) as -1 | 1;
if (!dir) return;
if (dir < 0) dir = -1;
else dir = 1;
manager.plugin.managers.snapshot.applyNext(dir);
}
},
{
name: 'focus-refs',
execute: ({ event, args, manager }) => {
@@ -185,6 +197,34 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
manager.audio.stop();
}
},
{
name: 'dispose-audio',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('dispose-audio' in args)) return;
manager.audio.dispose();
}
},
{
name: 'play-transition',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('play-transition' in args)) return;
manager.plugin.managers.animation.play(AnimateStateSnapshotTransition, {});
}
},
{
name: 'play-snapshots',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('play-snapshots' in args)) return;
manager.plugin.managers.snapshot.play({ restart: true });
}
},
{
name: 'stop-animation',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('stop-animation' in args)) return;
manager.plugin.managers.snapshot.stop();
}
},
];
export class MarkdownExtensionManager {

View File

@@ -25,7 +25,7 @@ export { PluginStateSnapshotManager };
interface StateManagerState {
current?: UUID,
currentAnimationFrame?: number,
currentAnimationTimeMs?: number,
entries: List<PluginStateSnapshotManager.Entry>,
isPlaying: boolean,
nextSnapshotDelayInMs: number
@@ -38,8 +38,8 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
private defaultSnapshotId: UUID | undefined = undefined;
protected updateState(state: Partial<StateManagerState>) {
if ('current' in state && !('curentAnimationFrame' in state)) {
return super.updateState({ ...state, currentAnimationFrame: 0 });
if ('current' in state && !('currentAnimationTimeMs' in state)) {
return super.updateState({ ...state, currentAnimationTimeMs: 0 });
} else {
return super.updateState(state);
}
@@ -168,18 +168,23 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
}
private animationFrameQueue = new SingleTaskQueue();
setSnapshotAnimationFrame(frame: number, load = false) {
if (this.updateState({ currentAnimationFrame: frame })) {
setSnapshotAnimationFrame(currentAnimationTimeMs: number, load = false) {
const entry = this.getEntry(this.state.current);
if (!entry) return;
const frameIndex = PluginState.getStateTransitionFrameIndex(entry.snapshot, currentAnimationTimeMs) ?? 0;
if (this.updateState({ currentAnimationTimeMs })) {
this.events.changed.next(void 0);
}
if (load) {
this.animationFrameQueue.run(() => {
const entry = this.getEntry(this.state.current);
if (!entry) return Promise.resolve();
return this.plugin.state.setAnimationSnapshot(entry.snapshot, frame);
return this.plugin.state.setAnimationSnapshot(entry.snapshot, frameIndex ?? 0);
});
}
return frameIndex;
}
getNextId(id: string | undefined, dir: -1 | 1) {
@@ -233,7 +238,7 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
const next = entry && entry.snapshot;
if (!next) return;
await this.plugin.state.setSnapshot(next);
if (snapshot.playback && snapshot.playback.isPlaying) this.play(true);
if (snapshot.playback?.isPlaying) this.play({ delayFirst: true });
return next;
}
@@ -380,10 +385,18 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
}
}
play(delayFirst: boolean = false) {
async play(options?: { delayFirst?: boolean, restart?: boolean }) {
if (this.state.isPlaying && !options?.delayFirst) {
if (options?.restart) {
await this.stop();
} else {
return;
}
}
this.updateState({ isPlaying: true });
if (delayFirst) {
if (options?.delayFirst) {
const e = this.getEntry(this.state.current);
if (!e) {
this.next();
@@ -398,21 +411,20 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
}
}
stop() {
this.plugin.managers.animation.stop();
async stop() {
await this.plugin.managers.animation.stop();
this.updateState({ isPlaying: false });
if (typeof this.timeoutHandle !== 'undefined') clearTimeout(this.timeoutHandle);
this.timeoutHandle = void 0;
this.events.changed.next(void 0);
}
togglePlay() {
async togglePlay() {
if (this.state.isPlaying) {
this.stop();
this.plugin.managers.animation.stop();
await this.stop();
this.plugin.managers.markdownExtensions.audio.pause();
} else {
this.play();
await this.play();
}
}

View File

@@ -29,6 +29,7 @@ import { StructureQuickStylesControls } from './structure/quick-styles';
import { Markdown } from './controls/markdown';
import { Slider } from './controls/slider';
import { AnimateStateSnapshotTransition } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { PluginState } from '../mol-plugin/state';
export class TrajectoryViewportControls extends PluginUIComponent<{}, { show: boolean, label: string }> {
state = { show: false, label: '' };
@@ -191,14 +192,12 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus
{hasAnimation && this.state.showAnimation && !isPlaying && <>
<div className='msp-state-snapshot-animation-slider msp-form-control'>
<Slider
value={snapshots.state.currentAnimationFrame ?? 0}
min={1}
step={1}
max={(entry?.snapshot.transition?.frames.length ?? 1)}
value={Math.round(100 * (snapshots.state.currentAnimationTimeMs ?? 0)) /100}
min={0}
step={PluginState.getMinFrameDuration(entry?.snapshot)}
max={PluginState.getStateTransitionDuration(entry?.snapshot) ?? 1000}
onChange={() => { }}
onChangeImmediate={v => {
snapshots.setSnapshotAnimationFrame(v - 1, true);
}}
onChangeImmediate={v => snapshots.setSnapshotAnimationFrame(v, true)}
hideInput
disabled={this.state.isBusy}
/>

View File

@@ -27,6 +27,7 @@ import { PluginConfig } from './config';
import { PluginContext } from './context';
import { AnimateStateSnapshotTransition } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { Scheduler } from '../mol-task';
import { memoizeLatest } from '../mol-util/memoize';
export { PluginState };
@@ -264,7 +265,23 @@ namespace PluginState {
}[],
}
export function getStateTransitionDuration(snapshot: Snapshot): number | undefined {
export const getMinFrameDuration = memoizeLatest((snapshot: Snapshot | undefined): number => {
if (!snapshot) return 1000 / 60;
const { transition } = snapshot;
if (!transition) return 1000 / 60;
let minDuration = Infinity;
for (const frame of transition.frames) {
if (frame.durationInMs > 0 && frame.durationInMs < minDuration) {
minDuration = frame.durationInMs;
}
}
if (!Number.isFinite(minDuration)) return 1000 / 60;
return minDuration;
});
export const getStateTransitionDuration = memoizeLatest((snapshot: Snapshot | undefined): number | undefined => {
if (!snapshot) return undefined;
const { transition } = snapshot;
if (!transition) return undefined;
let totalDuration = 0;
@@ -273,7 +290,20 @@ namespace PluginState {
totalDuration += frame.durationInMs;
}
return totalDuration;
}
});
export const getStateTransitionFrameTime = memoizeLatest((snapshot: Snapshot | undefined, frameIndex: number | undefined): number => {
if (!snapshot || frameIndex === undefined) return 0;
const { transition } = snapshot;
if (!transition) return 0;
let currentDuration = 0;
for (let i = 0; i < frameIndex; i++) {
if (transition.frames.length <= i) break;
const frame = transition.frames[i];
currentDuration += frame.durationInMs;
}
return currentDuration;
});
export function getStateTransitionFrameIndex(snapshot: Snapshot, timestamp: number): number | undefined {
const { transition } = snapshot;