Skip to content

Commit e836ffc

Browse files
committed
Add shimmer effect for ongoing caption generation
- Introduced a new `shimmer` function to visually indicate when a scene is being captioned, enhancing user experience. - Updated the `generateCaptions` function to prevent multiple caption generations on the same scene. - Added `Shimmering` trait to manage the shimmer state and timing. - Updated rendering logic to include the shimmer effect around scenes during processing. - Added `@diffusionstudio/api-contract` dependency to `package-lock.json` for improved API integration.
1 parent 38ecaef commit e836ffc

5 files changed

Lines changed: 187 additions & 24 deletions

File tree

‎apps/web/src/engine/generate/captions.tsx‎

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
/**
66
* Auto-captions: the scene's audible mix, encoded and transcribed, mounted as
77
* the scene's `<captions>`. A scene has no geometry to stand in with — it is
8-
* the frame everything else sits in — so the wait is told in a toast that
9-
* follows the job instead of a pulse.
8+
* the frame everything else sits in — so the wait is shown as the blue
9+
* shimmer around it instead of a pulse; only a failure is told in a toast.
1010
*/
1111

1212
import { Captions } from '@diffusionstudio/reconciler';
13-
import { Caption, getEntityTree, Library } from '@diffusionstudio/runtime';
13+
import { Caption, getEntityTree, Library, Shimmering } from '@diffusionstudio/runtime';
1414
import { assetName } from '@diffusionstudio/assets';
1515
import { toast } from 'somoto';
1616

@@ -19,9 +19,9 @@ import { uploadFile } from '@/lib/uploads';
1919

2020
import { getDocumentEditor } from '../editor';
2121
import { encodeSceneAudio, hasAudio } from '../scene-audio';
22+
import { shimmer } from './shimmer';
2223

2324
import type { AssetLibrary } from '@diffusionstudio/assets';
24-
import type { Job } from '@diffusionstudio/api-contract';
2525
import type { Entity, World } from 'koota';
2626

2727
const TITLE = 'Caption generation failed';
@@ -36,34 +36,25 @@ function captionsName(library: AssetLibrary): string {
3636
return `Captions ${max + 1}.json`;
3737
}
3838

39-
/** The toast line for a transcription in flight. */
40-
function status(job: Job | undefined): string {
41-
if (!job) return 'Preparing the audio…';
42-
if (job.status === 'queued') return 'Queued';
43-
if (job.phase === 'finalizing' || job.status === 'succeeded') return 'Almost done…';
44-
return 'Transcribing…';
45-
}
46-
4739
/**
4840
* Transcribes `scene` and mounts the transcript as its captions: the scene's
49-
* `<captions>` is pointed at the new transcript, or one is added.
41+
* `<captions>` is pointed at the new transcript, or one is added. A scene
42+
* already being captioned is left to the run in flight.
5043
*/
5144
export async function generateCaptions(world: World, scene: Entity, dir?: string): Promise<void> {
5245
const library = world.get(Library);
53-
if (!library) return;
46+
if (!library || scene.has(Shimmering)) return;
5447
if (!hasAudio(world, scene)) {
5548
toast(TITLE, { description: 'No audio found. Add an audio or video clip to the scene to generate captions.' });
5649
return;
5750
}
5851

59-
const id = `captions:${scene.id()}`;
60-
toast.loading('Generating captions', { id, description: status(undefined) });
52+
const release = shimmer(world, scene);
53+
getDocumentEditor(world).deselect(scene);
6154

6255
try {
6356
const audio = await uploadFile(await encodeSceneAudio(world, scene, dir));
64-
const job = await runJob({ model: 'universal-3.5-pro', audio }, (update) => {
65-
toast.loading('Generating captions', { id, description: status(update) });
66-
});
57+
const job = await runJob({ model: 'universal-3.5-pro', audio }, () => {});
6758
if (job.status !== 'succeeded') throw new Error(jobFailure(job));
6859

6960
const [file] = job.assets;
@@ -87,9 +78,10 @@ export async function generateCaptions(world: World, scene: Entity, dir?: string
8778
const [captions] = editor.insertElement(scene, () => <Captions src={asset.path} />);
8879
if (captions) editor.select(captions);
8980
}
90-
toast.dismiss(id);
9181
} catch (error) {
9282
console.error(`[generate] ${TITLE}:`, error);
93-
toast.error(TITLE, { id, description: error instanceof Error ? error.message : String(error) });
83+
toast.error(TITLE, { description: error instanceof Error ? error.message : String(error) });
84+
} finally {
85+
release?.();
9486
}
9587
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* The blue shimmer around a node that work is running on, for work that has
7+
* nothing to stand in with on the node itself: a scene is the frame
8+
* everything else sits in, so captioning it outlines it instead of pulsing.
9+
*
10+
* Like a placeholder, the node is held by its `Source` stamp, never by
11+
* entity: a remount or an undo/redo makes new entities, a stamp survives (and
12+
* a pending stamp is followed to the real one the write answers with). The
13+
* trait is put on again every `REFRESH`, so an entity that replaced the old
14+
* one picks it up.
15+
*/
16+
17+
import { Shimmering, Source, Time } from '@diffusionstudio/runtime';
18+
19+
import { getDocumentEditor } from '../editor';
20+
21+
import type { Entity, World } from 'koota';
22+
23+
/** How often the shimmer is put onto the node's entity. */
24+
const REFRESH = 250; // ms
25+
26+
/**
27+
* Outlines `entity` with the shimmer until the returned release is called.
28+
* Returns undefined for an entity the file cannot name.
29+
*/
30+
export function shimmer(world: World, entity: Entity): (() => void) | undefined {
31+
let source = entity.get(Source)?.value;
32+
if (!source) return undefined;
33+
34+
const start = world.get(Time)?.now ?? 0;
35+
const find = () => world.query(Source).find((candidate) => candidate.isAlive() && candidate.get(Source)!.value === source);
36+
37+
const apply = () => {
38+
const target = find();
39+
if (target && !target.has(Shimmering)) target.add(Shimmering({ start }));
40+
};
41+
42+
const unsubscribe = getDocumentEditor(world).onRename((ids) => {
43+
source = ids[source!] ?? source;
44+
});
45+
const timer = setInterval(apply, REFRESH);
46+
apply();
47+
48+
return () => {
49+
clearInterval(timer);
50+
unsubscribe();
51+
find()?.remove(Shimmering);
52+
};
53+
}

‎package-lock.json‎

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/runtime/src/systems/render.ts‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ import {
2121
ClipsContent, Geometry, Group, Paint, Color, Caption, ScaleMode, Shader,
2222
BlendMode, Effect, Mask, Transition, MixedCornerRadius,
2323
LocalTransform, WorldTransform, Computed, Cache,
24-
Host,
24+
Host, Scene, Shimmering,
2525
Mode, FrameRate, Camera, Background, RenderSurface,
2626
HitRegions,
27-
Root,
27+
Root, Time,
2828
} from '../traits';
2929
import { getParentNode } from '../queries/hierarchy';
3030
import { getViewMatrix } from '../queries/camera';
@@ -610,6 +610,103 @@ function renderPendingFill(world: World, entity: Entity): void {
610610
ctx.fill();
611611
}
612612

613+
const SHIMMER_PERIOD = 2400; // ms per turn of the highlights
614+
const SHIMMER_SWEEP = 1400; // ms the light takes to cross the scene after each turn
615+
const SHIMMER_BAND = 1; // width of the light, in diagonals of the scene
616+
const SHIMMER_PRIMARY = [0, 140, 255] as const; // #008CFF
617+
const SHIMMER_DEPTH = 0.7; // how far the highlights darken toward the stage background
618+
const SHIMMER_SWEEP_OPACITY = 0.2;
619+
620+
/**
621+
* The outline of a scene that work is running on (see `Shimmering`), over
622+
* everything the scene draws: a primary blue stroke along its edge with two
623+
* highlights travelling around it that darken toward the stage background,
624+
* and after each turn a band of translucent primary blue sweeping across the
625+
* scene, corner to corner. Counted on the world's clock from when
626+
* the work began. The editor's only, never drawn into an export. The stroke
627+
* keeps its width on screen whatever the zoom, so it is drawn in device
628+
* pixels from the scene's corners.
629+
*/
630+
function renderShimmer(world: World, entity: Entity): void {
631+
if (!entity.has(Shimmering) || !entity.has(Scene)) return;
632+
633+
const ctx = getCtx(world);
634+
const computed = store(world, Computed);
635+
const w = computed.width[entity.id()]!;
636+
const h = computed.height[entity.id()]!;
637+
const resolution = world.get(RenderSurface)?.resolution ?? 1;
638+
639+
const m = ctx.getTransform();
640+
const quad = [[0, 0], [w, 0], [w, h], [0, h]].map(([x, y]) => m.transformPoint({ x, y }));
641+
const cx = (quad[0]!.x + quad[1]!.x + quad[2]!.x + quad[3]!.x) / 4;
642+
const cy = (quad[0]!.y + quad[1]!.y + quad[2]!.y + quad[3]!.y) / 4;
643+
const trace = () => {
644+
ctx.beginPath();
645+
ctx.moveTo(quad[0]!.x, quad[0]!.y);
646+
ctx.lineTo(quad[1]!.x, quad[1]!.y);
647+
ctx.lineTo(quad[2]!.x, quad[2]!.y);
648+
ctx.lineTo(quad[3]!.x, quad[3]!.y);
649+
ctx.closePath();
650+
};
651+
652+
const elapsed = Math.max(0, (world.get(Time)?.now ?? 0) - entity.get(Shimmering)!.start);
653+
const turn = elapsed % SHIMMER_PERIOD;
654+
655+
ctx.save();
656+
ctx.resetTransform();
657+
ctx.globalAlpha = 1;
658+
ctx.globalCompositeOperation = 'source-over';
659+
ctx.filter = 'none';
660+
661+
if (elapsed >= SHIMMER_PERIOD && turn < SHIMMER_SWEEP) {
662+
// The band's centre goes from half a band before the first corner to
663+
// half a band past the opposite one: it enters and leaves the scene
664+
// exactly, and is on it the whole sweep.
665+
const t = turn / SHIMMER_SWEEP;
666+
const eased = (1 - Math.cos(Math.PI * t)) / 2;
667+
const centre = -SHIMMER_BAND / 2 + eased * (1 + SHIMMER_BAND);
668+
const dx = quad[2]!.x - quad[0]!.x;
669+
const dy = quad[2]!.y - quad[0]!.y;
670+
const from = centre - SHIMMER_BAND / 2;
671+
const to = centre + SHIMMER_BAND / 2;
672+
const light = ctx.createLinearGradient(
673+
quad[0]!.x + dx * from, quad[0]!.y + dy * from,
674+
quad[0]!.x + dx * to, quad[0]!.y + dy * to,
675+
);
676+
const [r, g, b] = SHIMMER_PRIMARY;
677+
// Full strength across the middle of the band, fading at its edges.
678+
light.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0)`);
679+
light.addColorStop(0.3, `rgba(${r}, ${g}, ${b}, ${SHIMMER_SWEEP_OPACITY})`);
680+
light.addColorStop(0.7, `rgba(${r}, ${g}, ${b}, ${SHIMMER_SWEEP_OPACITY})`);
681+
light.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
682+
683+
trace();
684+
ctx.fillStyle = light;
685+
ctx.fill();
686+
}
687+
688+
const background = world.get(Root)!.get(Background)?.value ?? 0;
689+
const backdrop = [(background >> 16) & 0xff, (background >> 8) & 0xff, background & 0xff];
690+
const primary = `rgb(${SHIMMER_PRIMARY.join(', ')})`;
691+
const dark = `rgb(${SHIMMER_PRIMARY.map((channel, i) => Math.round(channel + (backdrop[i]! - channel) * SHIMMER_DEPTH)).join(', ')})`;
692+
693+
const ring = ctx.createConicGradient(turn / SHIMMER_PERIOD * Math.PI * 2, cx, cy);
694+
for (const start of [0, 0.5]) {
695+
ring.addColorStop(start, primary);
696+
ring.addColorStop(start + 0.12, dark);
697+
ring.addColorStop(start + 0.24, primary);
698+
}
699+
ring.addColorStop(1, primary);
700+
701+
trace();
702+
ctx.strokeStyle = ring;
703+
ctx.lineWidth = Math.round(2 * resolution);
704+
ctx.shadowColor = primary;
705+
ctx.shadowBlur = 8 * resolution;
706+
ctx.stroke();
707+
ctx.restore();
708+
}
709+
613710
// ── WAVEFORM paint ─────────────────────────────────────
614711
//
615712
// Renders an audio asset's pre-computed peaks as a bar chart inside the
@@ -829,6 +926,8 @@ export function renderNode(world: World, entity: Entity): void {
829926
renderContent(world, entity, buildEffects(world, entity));
830927
}
831928

929+
renderShimmer(world, entity);
930+
832931
ctx.restore();
833932
}
834933

‎packages/runtime/src/traits/node.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export const ClipsContent = trait();
4949

5050
export const Generating = trait({ label: '', progress: undefined as number | undefined });
5151

52+
// On a scene that work is running on (being captioned): the stage outlines it
53+
// with the blue shimmer. A scene is the frame everything else sits in, so it
54+
// has nothing to stand in with the way a generating node pulses. `start` is
55+
// when the work began on the world's clock, which the shimmer's turns count
56+
// from.
57+
export const Shimmering = trait({ start: 0 });
58+
5259
export const Name = trait({ value: '' });
5360

5461
// Stable identity for entities.

0 commit comments

Comments
 (0)