Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions THIRD_PARTY_NOTICES
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
This product includes portions of PixiJS and gifuct-js.

PixiJS
Copyright (c) 2013-2023 Mathew Groves, Chad Engler

The MIT License

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

gifuct-js
Copyright (c) 2015 Matt Way

The MIT License (MIT)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"dist/shotstack-studio.es.js",
"dist/internal.umd.js",
"dist/internal.es.js",
"dist/**/*.d.ts"
"dist/**/*.d.ts",
"THIRD_PARTY_NOTICES"
],
"keywords": [
"shotstack",
Expand Down
118 changes: 80 additions & 38 deletions src/components/canvas/players/audio-player.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KeyframeBuilder } from "@animations/keyframe-builder";
import type { Edit } from "@core/edit-session";
import { sec } from "@core/timing/types";
import { type Size } from "@layouts/geometry";
import { AudioLoadParser } from "@loaders/audio-load-parser";
import { type AudioAsset, type ResolvedClip, type Keyframe } from "@schemas";
Expand All @@ -10,6 +11,7 @@ import { Player, PlayerType } from "./player";

export class AudioPlayer extends Player {
private audioResource: howler.Howl | null;
private loadedResourceIdentifier: string | null;
private isPlaying: boolean;

private volumeKeyframeBuilder!: KeyframeBuilder;
Expand All @@ -20,38 +22,54 @@ export class AudioPlayer extends Player {
super(edit, clipConfiguration, PlayerType.Audio);

this.audioResource = null;
this.loadedResourceIdentifier = null;
this.isPlaying = false;
this.syncTimer = 0;
}

public override async load(): Promise<void> {
await super.load();
const mediaTimingRevision = this.beginMediaTimingLoad();
try {
await super.load();
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) return;

const audioClipConfiguration = this.clipConfiguration.asset as AudioAsset;
const audioClipConfiguration = this.clipConfiguration.asset as AudioAsset;

const identifier = audioClipConfiguration.src;
if (!identifier) {
// Prompt-bearing assets route to pending placeholder players — reaching here without a src is invalid data
throw new Error("Audio asset has no src to load.");
}
const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: AudioLoadParser.Name };
const audioResource = await this.edit.assetLoader.load<howler.Howl>(identifier, loadOptions);
const identifier = audioClipConfiguration.src;
if (!identifier) {
// Prompt-bearing assets route to pending placeholder players — reaching here without a src is invalid data
throw new Error("Audio asset has no src to load.");
}
const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: AudioLoadParser.Name };
const audioResource = await this.edit.assetLoader.load<howler.Howl>(identifier, loadOptions);
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) {
if (audioResource) this.edit.assetLoader.release(identifier);
return;
}

const isValidAudioSource = audioResource instanceof howler.Howl;
if (!isValidAudioSource) {
throw new Error(`Invalid audio source '${audioClipConfiguration.src}'.`);
}
const isValidAudioSource = audioResource instanceof howler.Howl;
if (!isValidAudioSource) {
if (audioResource) this.edit.assetLoader.release(identifier);
throw new Error(`Invalid audio source '${audioClipConfiguration.src}'.`);
}

this.audioResource = audioResource;
this.audioResource = audioResource;
this.loadedResourceIdentifier = identifier;
this.completeMediaTimingLoad(mediaTimingRevision, sec(audioResource.duration()));

// Create volume keyframes after timing is resolved (not in constructor)
const baseVolume = typeof audioClipConfiguration.volume === "number" ? audioClipConfiguration.volume : 1;
this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioClipConfiguration, baseVolume), this.getLength(), baseVolume);
// Create volume keyframes after timing is resolved (not in constructor)
const baseVolume = typeof audioClipConfiguration.volume === "number" ? audioClipConfiguration.volume : 1;
this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioClipConfiguration, baseVolume), this.getLength(), baseVolume);

// Set initial volume immediately so the Howl never sits at the default of 1.0
this.audioResource.volume(this.getVolume());
// Set initial volume immediately so the Howl never sits at the default of 1.0
this.audioResource.volume(this.getVolume());

this.configureKeyframes();
this.configureKeyframes();
} catch (error) {
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) return;
this.completeMediaTimingLoad(mediaTimingRevision, null);
throw error;
}
}

public override update(deltaTime: number, elapsed: number): void {
Expand Down Expand Up @@ -112,34 +130,58 @@ export class AudioPlayer extends Player {
this.audioResource.unload();
}
this.audioResource = null;
this.loadedResourceIdentifier = null;

super.dispose();
}

/** Reload the audio asset when asset.src changes (e.g., merge field update or loadEdit) */
public override async reloadAsset(): Promise<void> {
if (this.audioResource) {
this.audioResource.stop();
this.audioResource.unload();
}
this.audioResource = null;
this.isPlaying = false;
this.syncTimer = 0;
const mediaTimingRevision = this.beginMediaTimingLoad();
try {
this.releaseLoadedResource();
if (this.audioResource) {
this.audioResource.stop();
this.audioResource.unload();
}
this.audioResource = null;
this.isPlaying = false;
this.syncTimer = 0;

const audioAsset = this.clipConfiguration.asset as AudioAsset;
const { src } = audioAsset;
if (!src) {
throw new Error("Audio asset has no src to load.");
}
const loadOptions: pixi.UnresolvedAsset = { src, parser: AudioLoadParser.Name };
const audioResource = await this.edit.assetLoader.load<howler.Howl>(src, loadOptions);
const audioAsset = this.clipConfiguration.asset as AudioAsset;
const { src } = audioAsset;
if (!src) throw new Error("Audio source is required.");
const loadOptions: pixi.UnresolvedAsset = { src, parser: AudioLoadParser.Name };
const audioResource = await this.edit.assetLoader.load<howler.Howl>(src, loadOptions);
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) {
if (audioResource) this.edit.assetLoader.release(src);
return;
}

if (!(audioResource instanceof howler.Howl)) {
if (audioResource) this.edit.assetLoader.release(src);
throw new Error(`Invalid audio source '${src}'.`);
}

if (!(audioResource instanceof howler.Howl)) {
throw new Error(`Invalid audio source '${audioAsset.src}'.`);
this.audioResource = audioResource;
this.loadedResourceIdentifier = src;
this.completeMediaTimingLoad(mediaTimingRevision, sec(audioResource.duration()));
this.audioResource.volume(this.getVolume());
} catch (error) {
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) return;
this.completeMediaTimingLoad(mediaTimingRevision, null);
throw error;
}
}

public override getLoadedResourceIdentifier(): string | null {
return this.loadedResourceIdentifier;
}

this.audioResource = audioResource;
this.audioResource.volume(this.getVolume());
private releaseLoadedResource(): void {
if (!this.loadedResourceIdentifier) return;
this.edit.assetLoader.release(this.loadedResourceIdentifier);
this.loadedResourceIdentifier = null;
}

public override reconfigureAfterRestore(): void {
Expand Down
93 changes: 79 additions & 14 deletions src/components/canvas/players/caption-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Player, PlayerType } from "@canvas/players/player";
import { type Cue, findActiveCue } from "@core/captions";
import type { Edit } from "@core/edit-session";
import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config";
import { isAliasReference } from "@core/timing/types";
import { isAliasReference, sec, type Seconds } from "@core/timing/types";
import { type Size, type Vector } from "@layouts/geometry";
import { SubtitleLoadParser, type SubtitleAsset } from "@loaders/subtitle-load-parser";
import { type ExtendedCaptionAsset, type ResolvedClip } from "@schemas";
Expand All @@ -12,6 +12,7 @@ import * as pixi from "pixi.js";
const PLACEHOLDER_TEXT = "Captions will appear here";

type CaptionState = { readonly kind: "loaded"; readonly cues: Cue[] } | { readonly kind: "placeholder" };
type CaptionLoadResult = { readonly state: CaptionState; readonly retainedIdentifier: string | null };

/**
* CaptionPlayer renders timed subtitle cues from SRT/VTT files.
Expand All @@ -24,21 +25,18 @@ export class CaptionPlayer extends Player {
private currentCue: Cue | null = null;
private background: pixi.Graphics | null = null;
private text: pixi.Text | null = null;
private loadedSubtitleIdentifier: string | null = null;

constructor(edit: Edit, clipConfiguration: ResolvedClip) {
super(edit, clipConfiguration, PlayerType.Caption);
}

public override async load(): Promise<void> {
const mediaTimingRevision = this.beginMediaTimingLoad();
await super.load();
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) return;

const captionAsset = this.clipConfiguration.asset as ExtendedCaptionAsset;

const fontFamily = captionAsset.font?.family ?? "Open Sans";
await this.loadFont(fontFamily);

this.state = isAliasReference(captionAsset.src) ? { kind: "placeholder" } : await this.loadSubtitles(captionAsset.src);

this.background = new pixi.Graphics();
this.contentContainer.addChild(this.background);

Expand All @@ -54,12 +52,56 @@ export class CaptionPlayer extends Player {
}

this.contentContainer.addChild(this.text);
this.configureKeyframes();

if (this.state.kind === "placeholder") {
this.showPlaceholder(captionAsset);
try {
const fontFamily = captionAsset.font?.family ?? "Open Sans";
await this.loadFont(fontFamily);

const result = isAliasReference(captionAsset.src)
? { state: { kind: "placeholder" as const }, retainedIdentifier: null }
: await this.loadSubtitles(captionAsset.src);
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) {
this.releaseSubtitle(result.retainedIdentifier);
return;
}
this.replaceLoadedSubtitle(result.retainedIdentifier);
this.state = result.state;
this.completeMediaTimingLoad(mediaTimingRevision, this.getCaptionDuration(result.state));

if (this.state.kind === "placeholder") {
this.showPlaceholder(captionAsset);
} else {
this.updateDisplay(null, captionAsset);
}
} catch (error) {
if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) return;
this.completeMediaTimingLoad(mediaTimingRevision, null);
throw error;
}
}

this.configureKeyframes();
public override async reloadAsset(): Promise<void> {
const mediaTimingRevision = this.beginMediaTimingLoad();
const captionAsset = this.clipConfiguration.asset as ExtendedCaptionAsset;
const result = isAliasReference(captionAsset.src)
? { state: { kind: "placeholder" as const }, retainedIdentifier: null }
: await this.loadSubtitles(captionAsset.src);

if (!this.isMediaTimingLoadCurrent(mediaTimingRevision)) {
this.releaseSubtitle(result.retainedIdentifier);
return;
}
this.replaceLoadedSubtitle(result.retainedIdentifier);
this.state = result.state;
this.currentCue = null;
this.completeMediaTimingLoad(mediaTimingRevision, this.getCaptionDuration(result.state));

if (result.state.kind === "placeholder") {
this.showPlaceholder(captionAsset);
} else {
this.updateDisplay(null, captionAsset);
}
}

public override update(deltaTime: number, elapsed: number): void {
Expand Down Expand Up @@ -94,6 +136,10 @@ export class CaptionPlayer extends Player {
this.currentCue = null;
}

public override getLoadedResourceIdentifier(): string | null {
return this.loadedSubtitleIdentifier;
}

public override getSize(): Size {
const captionAsset = this.clipConfiguration.asset as ExtendedCaptionAsset;

Expand All @@ -112,7 +158,7 @@ export class CaptionPlayer extends Player {
return { x: scale, y: scale };
}

private async loadSubtitles(src: string): Promise<CaptionState> {
private async loadSubtitles(src: string): Promise<CaptionLoadResult> {
try {
const loadOptions: pixi.UnresolvedAsset = {
src,
Expand All @@ -121,17 +167,36 @@ export class CaptionPlayer extends Player {
const subtitle = await this.edit.assetLoader.load<SubtitleAsset>(src, loadOptions);

if (subtitle) {
return { kind: "loaded", cues: subtitle.cues };
return { state: { kind: "loaded", cues: subtitle.cues }, retainedIdentifier: src };
}

console.warn("Failed to load subtitles");
return { kind: "placeholder" };
return { state: { kind: "placeholder" }, retainedIdentifier: null };
} catch (error) {
console.warn("Failed to load subtitles:", error);
return { kind: "placeholder" };
return { state: { kind: "placeholder" }, retainedIdentifier: null };
}
}

private getCaptionDuration(state: CaptionState): Seconds | null {
if (state.kind !== "loaded" || state.cues.length === 0) return null;
let maxEnd = Number.NEGATIVE_INFINITY;
for (const cue of state.cues) {
if (Number.isFinite(cue.end)) maxEnd = Math.max(maxEnd, cue.end);
}
return Number.isFinite(maxEnd) ? sec(maxEnd) : null;
}

private replaceLoadedSubtitle(identifier: string | null): void {
const previousIdentifier = this.loadedSubtitleIdentifier;
this.loadedSubtitleIdentifier = identifier;
this.releaseSubtitle(previousIdentifier);
}

private releaseSubtitle(identifier: string | null): void {
if (identifier) this.edit.assetLoader.release(identifier);
}

private showPlaceholder(captionAsset: ExtendedCaptionAsset): void {
const placeholderCue: Cue = { start: 0, end: Infinity, text: PLACEHOLDER_TEXT };
this.updateDisplay(placeholderCue, captionAsset);
Expand Down
Loading
Loading