diff --git a/.releaserc.json b/.releaserc.json index 6cdc8f5..6bd6a4e 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,23 +1,52 @@ { - "branches": ["main"], - "plugins": [ - ["@semantic-release/commit-analyzer", { - "preset": "angular", - "releaseRules": [ - { "message": "*fix/*", "release": "patch" }, - { "message": "*hotfix/*", "release": "patch" }, - { "message": "*feat/*", "release": "minor" }, - { "message": "*release/*", "release": "major" } - ] - }], - "@semantic-release/release-notes-generator", - ["@semantic-release/npm", { - "pkgRoot": "." - }], - ["@semantic-release/git", { - "assets": ["package.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - }], - "@semantic-release/github" - ] + "branches": ["main"], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": [ + { + "message": "*/fix/*", + "release": "patch" + }, + { + "message": "*/hotfix/*", + "release": "patch" + }, + { + "message": "*/feat/*", + "release": "minor" + }, + { + "message": "*/release/*", + "release": "major" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "feat", + "release": "minor" + } + ] + } + ], + "@semantic-release/release-notes-generator", + [ + "@semantic-release/npm", + { + "pkgRoot": "." + } + ], + [ + "@semantic-release/git", + { + "assets": ["package.json"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + "@semantic-release/github" + ] } diff --git a/package.json b/package.json index d75cb24..530ca28 100644 --- a/package.json +++ b/package.json @@ -96,8 +96,8 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { - "@shotstack/schemas": "1.8.7", - "@shotstack/shotstack-canvas": "^2.0.17", + "@shotstack/schemas": "1.9.3", + "@shotstack/shotstack-canvas": "^2.1.8", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index bc1c63d..cf6851a 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -70,6 +70,9 @@ export abstract class Player extends Entity { */ public clipId: string | null = null; + /** True when the player's asset needs external resolution (e.g. alias caption awaiting transcription). */ + public needsResolution = false; + protected edit: Edit; public clipConfiguration: ResolvedClip; diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 6950187..a0befb1 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -2,6 +2,7 @@ import { Player, PlayerType } from "@canvas/players/player"; import { Edit } from "@core/edit-session"; import { parseFontFamily, resolveFontPath, getFontDisplayName } from "@core/fonts/font-config"; import { extractFontNames, isGoogleFontUrl } from "@core/fonts/font-utils"; +import { isAliasReference } from "@core/timing/types"; import { type Size, type Vector } from "@layouts/geometry"; import { RichCaptionAssetSchema, type RichCaptionAsset, type ResolvedClip } from "@schemas"; import { @@ -38,16 +39,43 @@ export class RichCaptionPlayer extends Player { private words: WordTiming[] = []; private loadComplete: boolean = false; + private isPlaceholder = false; private readonly fontRegistrationCache = new Map>(); private lastRegisteredFontKey: string = ""; private pendingLayoutId: number = 0; + private resolvedPauseThreshold: number = 500; constructor(edit: Edit, clipConfiguration: ResolvedClip) { const { fit, ...configWithoutFit } = clipConfiguration; super(edit, configWithoutFit, PlayerType.RichCaption); } + private static createPlaceholderWords(clipLengthMs: number): WordTiming[] { + const phrase = ["Your", "captions", "will", "appear", "here"]; + const msPerWord = 400; + const phraseGapMs = 600; + const phraseDurationMs = phrase.length * msPerWord + phraseGapMs; + const spokenDurationMs = phrase.length * msPerWord; + const totalPhrases = Math.max(1, Math.ceil((clipLengthMs - spokenDurationMs) / phraseDurationMs) + 1); + const words: WordTiming[] = []; + + for (let p = 0; p < totalPhrases; p += 1) { + const phraseStart = p * phraseDurationMs; + for (let w = 0; w < phrase.length; w += 1) { + const start = phraseStart + w * msPerWord; + words.push({ + text: phrase[w], + start: Math.round(start), + end: Math.round(start + msPerWord), + confidence: 1 + }); + } + } + + return words; + } + public override async load(): Promise { await super.load(); @@ -62,8 +90,14 @@ export class RichCaptionPlayer extends Player { let words: WordTiming[]; if (richCaptionAsset.src) { - words = await this.fetchAndParseSubtitle(richCaptionAsset.src); - (richCaptionAsset as Record)['pauseThreshold'] = 5; + if (isAliasReference(richCaptionAsset.src)) { + words = RichCaptionPlayer.createPlaceholderWords(this.getLength() * 1000); + this.isPlaceholder = true; + this.needsResolution = true; + } else { + words = await this.fetchAndParseSubtitle(richCaptionAsset.src); + this.resolvedPauseThreshold = 5; + } } else { words = ((richCaptionAsset as RichCaptionAsset & { words?: WordTiming[] }).words ?? []).map((w: WordTiming) => ({ text: w.text, @@ -86,42 +120,7 @@ export class RichCaptionPlayer extends Player { console.warn(`RichCaptionPlayer: ${words.length} words exceeds soft limit of ${SOFT_WORD_LIMIT}. Performance may degrade.`); } - const canvasPayload = this.buildCanvasPayload(richCaptionAsset, words); - const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); - if (!canvasValidation.success) { - console.error("Canvas caption validation failed:", canvasValidation.error?.issues ?? canvasValidation.error); - this.createFallbackGraphic("Caption validation failed"); - return; - } - this.validatedAsset = canvasValidation.data; - this.words = words; - - this.fontRegistry = await FontRegistry.getSharedInstance(); - await this.registerFonts(richCaptionAsset); - this.lastRegisteredFontKey = `${richCaptionAsset.font?.family ?? "Roboto"}|${richCaptionAsset.font?.weight ?? 400}`; - - this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry); - - const { width, height } = this.getSize(); - const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); - - const canvasTextMeasurer = this.createCanvasTextMeasurer(); - if (canvasTextMeasurer) { - layoutConfig.measureTextWidth = canvasTextMeasurer; - } - - this.captionLayout = await this.layoutEngine.layoutCaption(words, layoutConfig); - - this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); - - this.canvas = document.createElement("canvas"); - this.canvas.width = width; - this.canvas.height = height; - this.painter = createWebPainter(this.canvas); - - this.renderFrameSync(0); - this.configureKeyframes(); - this.loadComplete = true; + await this.buildRenderPipeline(richCaptionAsset, words); } catch (error) { console.error("RichCaptionPlayer load failed:", error); this.cleanupResources(); @@ -140,6 +139,51 @@ export class RichCaptionPlayer extends Player { this.renderFrameSync(currentTimeMs); } + public override async reloadAsset(): Promise { + const asset = this.clipConfiguration.asset as RichCaptionAsset; + + // When src is an alias reference, reset to placeholder if previously resolved + if (!asset.src || isAliasReference(asset.src)) { + if (this.loadComplete && !this.isPlaceholder) { + this.resolvedPauseThreshold = 500; + this.words = RichCaptionPlayer.createPlaceholderWords(this.getLength() * 1000); + this.isPlaceholder = true; + this.needsResolution = true; + await this.reconfigure(); + } + return; + } + + this.loadComplete = false; + + if (this.texture) { + this.texture.destroy(); + this.texture = null; + } + if (this.sprite) { + this.sprite.destroy(); + this.sprite = null; + } + this.captionLayout = null; + this.validatedAsset = null; + this.generatorConfig = null; + this.canvas = null; + this.painter = null; + + this.isPlaceholder = false; + this.needsResolution = false; + + const words = await this.fetchAndParseSubtitle(asset.src); + this.resolvedPauseThreshold = 5; + + if (words.length === 0) { + this.createFallbackGraphic("No caption words found"); + return; + } + + await this.buildRenderPipeline(asset, words); + } + public override reconfigureAfterRestore(): void { super.reconfigureAfterRestore(); this.reconfigure(); @@ -153,6 +197,11 @@ export class RichCaptionPlayer extends Player { try { const asset = this.clipConfiguration.asset as RichCaptionAsset; + // Regenerate placeholder words when clip length changes (e.g. "end" re-resolved after video probed) + if (this.isPlaceholder) { + this.words = RichCaptionPlayer.createPlaceholderWords(this.getLength() * 1000); + } + const fontKey = `${asset.font?.family ?? "Roboto"}|${asset.font?.weight ?? 400}`; if (fontKey !== this.lastRegisteredFontKey) { await this.registerFonts(asset); @@ -183,6 +232,45 @@ export class RichCaptionPlayer extends Player { } } + private async buildRenderPipeline(asset: RichCaptionAsset, words: WordTiming[]): Promise { + const canvasPayload = this.buildCanvasPayload(asset, words); + const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); + if (!canvasValidation.success) { + console.error("Canvas caption validation failed:", canvasValidation.error?.issues ?? canvasValidation.error); + this.createFallbackGraphic("Caption validation failed"); + return; + } + this.validatedAsset = canvasValidation.data; + this.words = words; + + this.fontRegistry = await FontRegistry.getSharedInstance(); + await this.registerFonts(asset); + this.lastRegisteredFontKey = `${asset.font?.family ?? "Roboto"}|${asset.font?.weight ?? 400}`; + + this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry); + + const { width, height } = this.getSize(); + const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); + + const canvasTextMeasurer = this.createCanvasTextMeasurer(); + if (canvasTextMeasurer) { + layoutConfig.measureTextWidth = canvasTextMeasurer; + } + + this.captionLayout = await this.layoutEngine.layoutCaption(words, layoutConfig); + + this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); + + this.canvas = document.createElement("canvas"); + this.canvas.width = width; + this.canvas.height = height; + this.painter = createWebPainter(this.canvas); + + this.renderFrameSync(0); + this.configureKeyframes(); + this.loadComplete = true; + } + private renderFrameSync(timeMs: number): void { if (!this.layoutEngine || !this.captionLayout || !this.canvas || !this.painter || !this.validatedAsset || !this.generatorConfig) { return; @@ -381,9 +469,9 @@ export class RichCaptionPlayer extends Player { const payload: Record = { type: asset.type, words: words.map(w => ({ text: w.text, start: w.start, end: w.end, confidence: w.confidence })), - font: { family: resolvedFamily, ...asset.font }, + font: { ...asset.font, family: resolvedFamily }, width, - height, + height }; const optionalFields: Record = { @@ -396,7 +484,7 @@ export class RichCaptionPlayer extends Player { style: asset.style, wordAnimation: asset.wordAnimation, align: asset.align, - pauseThreshold: (asset as Record)['pauseThreshold'], + pauseThreshold: this.resolvedPauseThreshold }; for (const [key, value] of Object.entries(optionalFields)) { @@ -406,7 +494,7 @@ export class RichCaptionPlayer extends Player { } if (customFonts.length > 0) { - payload['customFonts'] = customFonts; + payload["customFonts"] = customFonts; } return payload; @@ -414,24 +502,40 @@ export class RichCaptionPlayer extends Player { private buildLayoutConfig(asset: CanvasRichCaptionAsset, frameWidth: number, frameHeight: number): CaptionLayoutConfig { const { font, style, align, padding: rawPadding } = asset; - const padding = typeof rawPadding === "number" ? rawPadding : (rawPadding?.left ?? 0); + + let padding: { top: number; right: number; bottom: number; left: number }; + if (typeof rawPadding === "number") { + padding = { top: rawPadding, right: rawPadding, bottom: rawPadding, left: rawPadding }; + } else if (rawPadding) { + const p = rawPadding as { top?: number; right?: number; bottom?: number; left?: number }; + padding = { top: p.top ?? 0, right: p.right ?? 0, bottom: p.bottom ?? 0, left: p.left ?? 0 }; + } else { + padding = { top: 0, right: 0, bottom: 0, left: 0 }; + } + + const totalHorizontalPadding = padding.left + padding.right; + const availableWidth = totalHorizontalPadding > 0 ? frameWidth - totalHorizontalPadding : frameWidth * 0.9; + + const fontSize = font?.size ?? 24; + const lineHeight = style?.lineHeight ?? 1.2; + const availableHeight = frameHeight - padding.top - padding.bottom; + const maxLines = Math.max(1, Math.min(10, Math.floor(availableHeight / (fontSize * lineHeight)))); return { frameWidth, frameHeight, - availableWidth: frameWidth * 0.9, - maxLines: 2, - verticalAlign: align?.vertical ?? "bottom", + availableWidth, + maxLines, + verticalAlign: align?.vertical ?? "middle", horizontalAlign: align?.horizontal ?? "center", - paddingLeft: padding, - fontSize: font?.size ?? 24, + padding, + fontSize, fontFamily: font?.family ?? "Roboto", fontWeight: String(font?.weight ?? "400"), letterSpacing: style?.letterSpacing ?? 0, - wordSpacing: typeof style?.wordSpacing === "number" ? style.wordSpacing : 0, - lineHeight: style?.lineHeight ?? 1.2, + lineHeight, textTransform: (style?.textTransform as CaptionLayoutConfig["textTransform"]) ?? "none", - pauseThreshold: asset.pauseThreshold ?? 500 + pauseThreshold: this.resolvedPauseThreshold }; } @@ -530,20 +634,56 @@ export class RichCaptionPlayer extends Player { } protected override onDimensionsChanged(): void { - if (!this.layoutEngine || !this.validatedAsset || !this.canvas || !this.painter) return; + if (this.words.length === 0) return; - const { width, height } = this.getSize(); + this.rebuildForCurrentSize(); + } - this.canvas.width = width; - this.canvas.height = height; + private async rebuildForCurrentSize(): Promise { + const currentTimeMs = this.getPlaybackTime() * 1000; if (this.texture) { this.texture.destroy(); this.texture = null; } + if (this.sprite) { + this.contentContainer.removeChild(this.sprite); + this.sprite.destroy(); + this.sprite = null; + } + if (this.contentContainer.mask) { + const { mask } = this.contentContainer; + this.contentContainer.mask = null; + if (mask instanceof pixi.Graphics) { + mask.destroy(); + } + } + + this.captionLayout = null; + this.validatedAsset = null; + this.generatorConfig = null; + this.canvas = null; + this.painter = null; + + const { width, height } = this.getSize(); + const asset = this.clipConfiguration.asset as RichCaptionAsset; + + const canvasPayload = this.buildCanvasPayload(asset, this.words); + const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); + if (!canvasValidation.success) { + return; + } + this.validatedAsset = canvasValidation.data; this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); + this.canvas = document.createElement("canvas"); + this.canvas.width = width; + this.canvas.height = height; + this.painter = createWebPainter(this.canvas); + + if (!this.layoutEngine) return; + const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); const canvasTextMeasurer = this.createCanvasTextMeasurer(); if (canvasTextMeasurer) { @@ -552,11 +692,14 @@ export class RichCaptionPlayer extends Player { this.pendingLayoutId += 1; const layoutId = this.pendingLayoutId; - this.layoutEngine.layoutCaption(this.words, layoutConfig).then(layout => { - if (layoutId !== this.pendingLayoutId) return; - this.captionLayout = layout; - this.renderFrameSync(this.getPlaybackTime() * 1000); - }); + + const layout = await this.layoutEngine.layoutCaption(this.words, layoutConfig); + + if (layoutId !== this.pendingLayoutId) return; + + this.captionLayout = layout; + + this.renderFrameSync(currentTimeMs); } public override supportsEdgeResize(): boolean { diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts index de37598..e912e12 100644 --- a/src/components/timeline/components/clip/clip-component.ts +++ b/src/components/timeline/components/clip/clip-component.ts @@ -130,6 +130,7 @@ export class ClipComponent { this.element.classList.toggle("selected", clip.visualState === "selected"); this.element.classList.toggle("dragging", clip.visualState === "dragging"); this.element.classList.toggle("resizing", clip.visualState === "resizing"); + this.element.classList.toggle("focused", clip.isFocused); // Update icon (using cached reference) if (this.iconEl && this.iconEl.dataset["assetType"] !== assetType) { diff --git a/src/components/timeline/timeline-state.ts b/src/components/timeline/timeline-state.ts index e663d78..7e775dc 100644 --- a/src/components/timeline/timeline-state.ts +++ b/src/components/timeline/timeline-state.ts @@ -12,6 +12,8 @@ export class TimelineStateManager { /** Track luma visibility by clip ID for stability across reconciliation */ private lumaEditingVisibleByClipId = new Set(); private cachedTracks: TrackState[] | null = null; + private focusedTrackIndex = -1; + private focusedClipIndex = -1; constructor( private readonly edit: Edit, @@ -35,12 +37,28 @@ export class TimelineStateManager { // Selection changes are UI state (not document mutations) this.edit.events.on(EditEvent.ClipSelected, this.invalidateCache); this.edit.events.on(EditEvent.SelectionCleared, this.invalidateCache); + + // Focus changes (visual highlight from source popup hover) + this.edit.getInternalEvents().on(InternalEvent.ClipFocused, this.onClipFocused); + this.edit.getInternalEvents().on(InternalEvent.ClipBlurred, this.onClipBlurred); } private invalidateCache = (): void => { this.cachedTracks = null; }; + private onClipFocused = ({ trackIndex, clipIndex }: { trackIndex: number; clipIndex: number }): void => { + this.focusedTrackIndex = trackIndex; + this.focusedClipIndex = clipIndex; + this.invalidateCache(); + }; + + private onClipBlurred = (): void => { + this.focusedTrackIndex = -1; + this.focusedClipIndex = -1; + this.invalidateCache(); + }; + // ========== Derived from Edit (memoized) ========== public getTracks(): TrackState[] { @@ -219,6 +237,8 @@ export class TimelineStateManager { this.edit.events.off(EditEvent.TimelineUpdated, this.invalidateCache); this.edit.events.off(EditEvent.ClipSelected, this.invalidateCache); this.edit.events.off(EditEvent.SelectionCleared, this.invalidateCache); + this.edit.getInternalEvents().off(InternalEvent.ClipFocused, this.onClipFocused); + this.edit.getInternalEvents().off(InternalEvent.ClipBlurred, this.onClipBlurred); // Clear state this.cachedTracks = null; @@ -241,6 +261,7 @@ export class TimelineStateManager { const isSelected = this.edit.isClipSelected(trackIndex, clipIndex); const visualState = this.getComputedVisualState(trackIndex, clipIndex, isSelected); + const isFocused = trackIndex === this.focusedTrackIndex && clipIndex === this.focusedClipIndex; return { id: clip.id, @@ -248,6 +269,7 @@ export class TimelineStateManager { clipIndex, config: clip, visualState, + isFocused, timingIntent: { start: unresolvedClip?.start === "auto" ? "auto" : clip.start, length: unresolvedClip?.length === "auto" || unresolvedClip?.length === "end" ? unresolvedClip.length : clip.length diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index f243443..a5376eb 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -1,6 +1,6 @@ import { CreateTrackMoveAndDetachLumaCommand } from "@core/commands/create-track-move-and-detach-luma-command"; import type { Edit } from "@core/edit-session"; -import { EditEvent } from "@core/events/edit-events"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { computeAiAssetNumber, type ResolvedClipWithId } from "@core/shared/ai-asset-utils"; import { inferAssetTypeFromUrl } from "@core/shared/asset-utils"; import { type Seconds, sec } from "@core/timing/types"; @@ -64,6 +64,7 @@ export class Timeline { private readonly handleClipSelected: () => void; private readonly handleClipLoadFailed: () => void; private readonly handleClipUpdated: () => void; + private readonly handleClipFocusChanged: () => void; private readonly handleRulerMouseMove: (e: MouseEvent) => void; constructor( @@ -113,6 +114,7 @@ export class Timeline { this.handleClipSelected = () => this.requestRender(); this.handleClipLoadFailed = () => this.requestRender(); this.handleClipUpdated = () => this.requestRender(); + this.handleClipFocusChanged = () => this.requestRender(); this.handleRulerMouseMove = (e: MouseEvent) => { if (!this.playheadGhost || !this.rulerTracksWrapper) return; const rect = this.rulerTracksWrapper.getBoundingClientRect(); @@ -250,6 +252,11 @@ export class Timeline { // Listen for clip load failures (to show error badge on timeline) this.edit.events.on(EditEvent.ClipLoadFailed, this.handleClipLoadFailed); + + // Listen for focus changes (source popup hover-to-highlight) + const internal = this.edit.getInternalEvents(); + internal.on(InternalEvent.ClipFocused, this.handleClipFocusChanged); + internal.on(InternalEvent.ClipBlurred, this.handleClipFocusChanged); } private removeEventListeners(): void { @@ -264,6 +271,10 @@ export class Timeline { this.edit.events.off(EditEvent.ClipSelected, this.handleClipSelected); this.edit.events.off(EditEvent.ClipUpdated, this.handleClipUpdated); this.edit.events.off(EditEvent.ClipLoadFailed, this.handleClipLoadFailed); + + const internal = this.edit.getInternalEvents(); + internal.off(InternalEvent.ClipFocused, this.handleClipFocusChanged); + internal.off(InternalEvent.ClipBlurred, this.handleClipFocusChanged); } /** Start continuous render loop (during playback or interaction) */ diff --git a/src/components/timeline/timeline.types.ts b/src/components/timeline/timeline.types.ts index 69a71b1..ab1f570 100644 --- a/src/components/timeline/timeline.types.ts +++ b/src/components/timeline/timeline.types.ts @@ -16,6 +16,8 @@ export interface ClipState { config: ResolvedClip; /** Visual state */ visualState: ClipVisualState; + /** Whether this clip has visual focus (e.g. hover-to-highlight from source popup) */ + isFocused: boolean; /** Original timing intent before resolution */ timingIntent: { start: "auto" | number; diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 39c8262..ff478af 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -24,6 +24,7 @@ import { LumaMaskController } from "@core/luma-mask-controller"; import { MergeFieldService, type SerializedMergeField } from "@core/merge"; import { calculateSizeFromPreset, OutputSettingsManager } from "@core/output-settings-manager"; import { SelectionManager } from "@core/selection-manager"; +import { findEligibleSourceClips, ensureClipAlias } from "@core/shared/source-clip-finder"; import { deepMerge, setNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart } from "@core/timing/resolver"; import { type Milliseconds, type ResolutionContext, type Seconds, sec, toSec, isAliasReference } from "@core/timing/types"; @@ -781,6 +782,28 @@ export class Edit { for (const clip of track.clips) { await this.addClip(trackIdx, clip); } + + // Auto-link caption clips with unresolved alias sources + await this.autoLinkCaptionSources(trackIdx, track.clips); + } + + /** + * Auto-link rich-caption clips to the first eligible source clip. + * If an alias reference in the caption's src can't be resolved, link it automatically. + */ + private async autoLinkCaptionSources(trackIdx: number, clips: Clip[]): Promise { + for (let c = 0; c < clips.length; c += 1) { + const clip = clips[c]; + const asset = clip.asset as { type?: string; src?: string }; + if (asset.type === "rich-caption" && isAliasReference(asset.src)) { + const eligible = findEligibleSourceClips(this); + if (eligible.length > 0) { + const target = eligible[0]; + const alias = await ensureClipAlias(this, target.trackIndex, target.clipIndex); + await this.updateClip(trackIdx, c, { asset: { src: `alias://${alias}` } } as Record); + } + } + } } public getTrack(trackIdx: number): Track | null { @@ -1676,6 +1699,16 @@ export class Edit { this.selectionManager.selectClip(trackIndex, clipIndex); } + /** @internal – Visual focus without selection change or public event. */ + public focusClip(trackIndex: number, clipIndex: number): void { + this.internalEvents.emit(InternalEvent.ClipFocused, { trackIndex, clipIndex }); + } + + /** @internal – Clear visual focus. */ + public blurClip(): void { + this.internalEvents.emit(InternalEvent.ClipBlurred); + } + /** @internal */ public clearSelection(): void { this.selectionManager.clearSelection(); diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts index 7e909e1..dc05832 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -124,7 +124,11 @@ export const InternalEvent = { PlayerLoaded: "player:loaded", TrackContainerRemoved: "track:containerRemoved", ViewportSizeChanged: "viewport:sizeChanged", - ViewportNeedsZoomToFit: "viewport:needsZoomToFit" + ViewportNeedsZoomToFit: "viewport:needsZoomToFit", + + // Focus (visual highlight without selection change) + ClipFocused: "clip:focused", + ClipBlurred: "clip:blurred" } as const; // ───────────────────────────────────────────────────────────── @@ -209,4 +213,8 @@ export type InternalEventMap = { backgroundColor: string; }; [InternalEvent.ViewportNeedsZoomToFit]: void; + + // Focus + [InternalEvent.ClipFocused]: { trackIndex: number; clipIndex: number }; + [InternalEvent.ClipBlurred]: void; }; diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts index d9271f4..17bf1fc 100644 --- a/src/core/player-reconciler.ts +++ b/src/core/player-reconciler.ts @@ -212,6 +212,16 @@ export class PlayerReconciler { clipId }); } + + // Emit ClipUnresolved for players that need external resolution (e.g. alias captions) + if (player.needsResolution) { + this.edit.getInternalEvents().emit(EditEvent.ClipUnresolved, { + trackIndex, + clipIndex, + assetType, + clipId + }); + } }) .catch(error => { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/core/shared/source-clip-finder.ts b/src/core/shared/source-clip-finder.ts new file mode 100644 index 0000000..4ea20ed --- /dev/null +++ b/src/core/shared/source-clip-finder.ts @@ -0,0 +1,163 @@ +import type { Edit } from "@core/edit-session"; +import { isAliasReference, parseAliasName } from "@core/timing/types"; + +export const UNLINKED_SOURCE = "alias://"; + +export interface SourceClipInfo { + trackIndex: number; + clipIndex: number; + clipId: string; + assetType: "video" | "audio" | "text-to-speech"; + displayLabel: string; + currentAlias: string | undefined; +} + +const ELIGIBLE_TYPES = new Set(["video", "audio", "text-to-speech"]); + +const TYPE_LABELS: Record = { + video: "Video", + audio: "Audio", + "text-to-speech": "TTS" +}; + +const MERGE_FIELD_PATTERN = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/; + +const AUTO_ALIAS_PATTERN = /^source_[\da-f]{8}$/i; + +function isUserAlias(alias: string | undefined): alias is string { + return !!alias && !AUTO_ALIAS_PATTERN.test(alias); +} + +function extractFilename(url: string): string | null { + try { + const { pathname } = new URL(url); + const filename = pathname.split("/").pop(); + if (!filename) return null; + return decodeURIComponent(filename.replace(/\.[^.]+$/, "")); + } catch { + return null; + } +} + +function truncateText(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen).trimEnd()}...`; +} + +function buildDisplayLabel( + clip: Record, + assetType: string, + trackIndex: number, + clipIndex: number +): string { + const typeLabel = TYPE_LABELS[assetType] ?? assetType; + const { alias } = clip as { alias?: string }; + const asset = clip["asset"] as Record; + const suffix = `Track ${trackIndex + 1} · Clip ${clipIndex + 1}`; + + // 1. User-set alias (skip auto-generated) + if (isUserAlias(alias)) { + return `${alias} · ${typeLabel} · ${suffix}`; + } + + // 2. TTS: text preview + voice + if (assetType === "text-to-speech") { + const text = (asset["text"] as string) ?? ""; + const voice = (asset["voice"] as string) ?? ""; + const preview = truncateText(text, 25); + const voiceSuffix = voice ? ` (${voice})` : ""; + return `"${preview}"${voiceSuffix} · ${suffix}`; + } + + // 3. Merge field + const src = (asset["src"] as string) ?? ""; + const mergeMatch = MERGE_FIELD_PATTERN.exec(src); + if (mergeMatch) { + return `${mergeMatch[1]} · ${typeLabel} · ${suffix}`; + } + + // 4. Filename + const filename = extractFilename(src); + if (filename) { + return `${filename} · ${typeLabel} · ${suffix}`; + } + + // 5. Fallback + return `${typeLabel} · ${suffix}`; +} + +/** + * Scan all tracks for video/audio/TTS clips eligible as caption sources. + * Returns results in bottom-to-top order (highest track index first). + */ +export function findEligibleSourceClips(edit: Edit): SourceClipInfo[] { + const doc = edit.getDocument(); + if (!doc) return []; + + const results: SourceClipInfo[] = []; + const trackCount = doc.getTrackCount(); + + // Bottom-to-top: iterate from last track to first + for (let t = trackCount - 1; t >= 0; t -= 1) { + const clips = doc.getClipsInTrack(t); + clips.forEach((clip, c) => { + const assetType = (clip.asset as { type?: string })?.type; + if (!assetType || !ELIGIBLE_TYPES.has(assetType)) return; + + const clipId = edit.getClipId(t, c); + if (!clipId) return; + + results.push({ + trackIndex: t, + clipIndex: c, + clipId, + assetType: assetType as SourceClipInfo["assetType"], + displayLabel: buildDisplayLabel(clip as Record, assetType, t, c), + currentAlias: (clip as { alias?: string }).alias + }); + }); + } + + return results; +} + +/** + * Find which source clip a caption is currently linked to by parsing its asset.src alias. + */ +export function findCurrentSource(edit: Edit, captionTrackIdx: number, captionClipIdx: number): SourceClipInfo | null { + const captionClip = edit.getDocumentClip(captionTrackIdx, captionClipIdx); + if (!captionClip) return null; + + const src = (captionClip.asset as { src?: string })?.src; + if (!src || !isAliasReference(src)) return null; + + const aliasName = parseAliasName(src); + if (!aliasName) return null; + const eligible = findEligibleSourceClips(edit); + return eligible.find(info => info.currentAlias === aliasName) ?? null; +} + +/** + * Generate a deterministic alias from a clip ID. + * Uses the last 8 characters of the ID (stable across track reorders). + */ +export function generateAlias(clipId: string): string { + return `source_${clipId.slice(-8)}`; +} + +/** + * Ensure a target clip has an alias. If it already has one, return it. + * Otherwise generate one and apply it via edit.updateClip(). + */ +export async function ensureClipAlias(edit: Edit, trackIdx: number, clipIdx: number): Promise { + const clip = edit.getDocumentClip(trackIdx, clipIdx); + const existing = (clip as { alias?: string } | null)?.alias; + if (existing) return existing; + + const clipId = edit.getClipId(trackIdx, clipIdx); + if (!clipId) throw new Error(`No clip ID at track ${trackIdx}, clip ${clipIdx}`); + + const alias = generateAlias(clipId); + await edit.updateClip(trackIdx, clipIdx, { alias } as Record); + return alias; +} diff --git a/src/core/timing-manager.ts b/src/core/timing-manager.ts index 6223117..cb3a4c2 100644 --- a/src/core/timing-manager.ts +++ b/src/core/timing-manager.ts @@ -65,6 +65,10 @@ export class TimingManager { } player.setResolvedTiming({ start: resolvedStart, length: resolvedLength }); + + // Sync resolved edit cache so timeline UI sees actual timing + resolvedClip.start = resolvedStart; + resolvedClip.length = resolvedLength; } } } @@ -78,10 +82,20 @@ export class TimingManager { const endLengthClips = this.getEndLengthClips(); for (const clip of endLengthClips) { const currentTiming = clip.getResolvedTiming(); + const endLength = resolveEndLength(currentTiming.start, timelineEnd); clip.setResolvedTiming({ start: currentTiming.start, - length: resolveEndLength(currentTiming.start, timelineEnd) + length: endLength }); + + // Sync resolved edit cache for "end" clips + const trackIdx = clip.layer - 1; + const clipIdx = tracks[trackIdx]?.indexOf(clip) ?? -1; + const endResolvedClip = resolved.timeline.tracks[trackIdx]?.clips[clipIdx]; + if (endResolvedClip) { + endResolvedClip.start = currentTiming.start; + endResolvedClip.length = endLength; + } } // Reconfigure "end" clips to rebuild keyframes diff --git a/src/core/timing/types.ts b/src/core/timing/types.ts index 28d110c..b6755dd 100644 --- a/src/core/timing/types.ts +++ b/src/core/timing/types.ts @@ -63,7 +63,7 @@ export type AliasReference = `alias://${string}`; /** Check if a value is an alias reference. Pattern shared with captions/alias-resolver.ts. */ export function isAliasReference(value: unknown): value is AliasReference { - return typeof value === "string" && /^alias:\/\/[a-zA-Z0-9_-]+$/.test(value); + return typeof value === "string" && /^alias:\/\/[a-zA-Z0-9_-]*$/.test(value); } /** Extract alias name from reference. */ diff --git a/src/core/ui/composites/SpacingPanel.ts b/src/core/ui/composites/SpacingPanel.ts index 8f4b303..3c3adb7 100644 --- a/src/core/ui/composites/SpacingPanel.ts +++ b/src/core/ui/composites/SpacingPanel.ts @@ -5,6 +5,7 @@ import { UIComponent } from "../primitives/UIComponent"; */ export interface SpacingState { letterSpacing: number; + wordSpacing: number; lineHeight: number; } @@ -18,6 +19,12 @@ export interface SpacingPanelConfig { letterSpacingMin?: number; /** Max value for letter spacing (default: 100) */ letterSpacingMax?: number; + /** Whether to show word spacing control (default: true) */ + showWordSpacing?: boolean; + /** Min value for word spacing (default: 0) */ + wordSpacingMin?: number; + /** Max value for word spacing (default: 100) */ + wordSpacingMax?: number; /** Min value for line height slider (default: 5, represents 0.5) */ lineHeightMin?: number; /** Max value for line height slider (default: 30, represents 3.0) */ @@ -46,12 +53,15 @@ export class SpacingPanel extends UIComponent { private state: SpacingState = { letterSpacing: 0, + wordSpacing: 0, lineHeight: 1.2 }; // DOM references private letterSpacingSlider: HTMLInputElement | null = null; private letterSpacingValue: HTMLSpanElement | null = null; + private wordSpacingSlider: HTMLInputElement | null = null; + private wordSpacingValue: HTMLSpanElement | null = null; private lineHeightSlider: HTMLInputElement | null = null; private lineHeightValue: HTMLSpanElement | null = null; @@ -68,13 +78,16 @@ export class SpacingPanel extends UIComponent { showLetterSpacing: panelConfig.showLetterSpacing ?? true, letterSpacingMin: panelConfig.letterSpacingMin ?? -50, letterSpacingMax: panelConfig.letterSpacingMax ?? 100, + showWordSpacing: panelConfig.showWordSpacing ?? false, + wordSpacingMin: panelConfig.wordSpacingMin ?? 0, + wordSpacingMax: panelConfig.wordSpacingMax ?? 100, lineHeightMin: panelConfig.lineHeightMin ?? 5, lineHeightMax: panelConfig.lineHeightMax ?? 30 }; } render(): string { - const { showLetterSpacing, letterSpacingMin, letterSpacingMax, lineHeightMin, lineHeightMax } = this.panelConfig; + const { showLetterSpacing, letterSpacingMin, letterSpacingMax, showWordSpacing, wordSpacingMin, wordSpacingMax, lineHeightMin, lineHeightMax } = this.panelConfig; const letterSpacingHtml = showLetterSpacing ? ` @@ -87,8 +100,20 @@ export class SpacingPanel extends UIComponent { ` : ""; + const wordSpacingHtml = showWordSpacing + ? ` +
Word spacing
+
+ + 0 +
+ ` + : ""; + return ` ${letterSpacingHtml} + ${wordSpacingHtml}
Line spacing
{ protected bindElements(): void { this.letterSpacingSlider = this.container?.querySelector("[data-letter-spacing-slider]") ?? null; this.letterSpacingValue = this.container?.querySelector("[data-letter-spacing-value]") ?? null; + this.wordSpacingSlider = this.container?.querySelector("[data-word-spacing-slider]") ?? null; + this.wordSpacingValue = this.container?.querySelector("[data-word-spacing-value]") ?? null; this.lineHeightSlider = this.container?.querySelector("[data-line-height-slider]") ?? null; this.lineHeightValue = this.container?.querySelector("[data-line-height-value]") ?? null; } @@ -120,6 +147,7 @@ export class SpacingPanel extends UIComponent { }; setupPointerdown(this.letterSpacingSlider); + setupPointerdown(this.wordSpacingSlider); setupPointerdown(this.lineHeightSlider); // Phase 2: Live update during drag @@ -132,6 +160,15 @@ export class SpacingPanel extends UIComponent { }); } + if (this.wordSpacingSlider) { + this.events.on(this.wordSpacingSlider, "input", () => { + const value = parseInt(this.wordSpacingSlider!.value, 10); + this.state.wordSpacing = value; + this.updateWordSpacingDisplay(); + this.emit(this.state); + }); + } + if (this.lineHeightSlider) { this.events.on(this.lineHeightSlider, "input", () => { const rawValue = parseInt(this.lineHeightSlider!.value, 10); @@ -151,14 +188,16 @@ export class SpacingPanel extends UIComponent { }; if (this.letterSpacingSlider) this.events.on(this.letterSpacingSlider, "change", onDragEnd); + if (this.wordSpacingSlider) this.events.on(this.wordSpacingSlider, "change", onDragEnd); if (this.lineHeightSlider) this.events.on(this.lineHeightSlider, "change", onDragEnd); } /** * Set spacing values from clip data. */ - setState(letterSpacing: number, lineHeight: number): void { + setState(letterSpacing: number, wordSpacing: number, lineHeight: number): void { this.state.letterSpacing = letterSpacing; + this.state.wordSpacing = wordSpacing; this.state.lineHeight = lineHeight; this.updateUI(); } @@ -207,11 +246,15 @@ export class SpacingPanel extends UIComponent { private updateUI(): void { this.updateLetterSpacingDisplay(); + this.updateWordSpacingDisplay(); this.updateLineHeightDisplay(); if (this.letterSpacingSlider) { this.letterSpacingSlider.value = String(this.state.letterSpacing); } + if (this.wordSpacingSlider) { + this.wordSpacingSlider.value = String(this.state.wordSpacing); + } if (this.lineHeightSlider) { this.lineHeightSlider.value = String(Math.round(this.state.lineHeight * 10)); } @@ -223,6 +266,12 @@ export class SpacingPanel extends UIComponent { } } + private updateWordSpacingDisplay(): void { + if (this.wordSpacingValue) { + this.wordSpacingValue.textContent = String(this.state.wordSpacing); + } + } + private updateLineHeightDisplay(): void { if (this.lineHeightValue) { this.lineHeightValue.textContent = this.state.lineHeight.toFixed(1); diff --git a/src/core/ui/font-color-picker.ts b/src/core/ui/font-color-picker.ts index 65792e9..7988bf2 100644 --- a/src/core/ui/font-color-picker.ts +++ b/src/core/ui/font-color-picker.ts @@ -314,12 +314,17 @@ type ColorMode = "color" | "gradient"; type FontColorChangeCallback = (updates: { color?: string; opacity?: number; - background?: string; + background?: string | undefined; gradient?: { type: "linear" | "radial"; angle: number; stops: Array<{ offset: number; color: string }> }; }) => void; +export interface FontColorPickerOptions { + hideGradient?: boolean; +} + export class FontColorPicker { private container: HTMLDivElement | null = null; + private hideGradient: boolean; // Tab buttons private colorTab: HTMLButtonElement | null = null; @@ -336,6 +341,7 @@ export class FontColorPicker { // Highlight elements (in color tab) private highlightColorInput: HTMLInputElement | null = null; + private highlightToggle: HTMLInputElement | null = null; private onColorChange: FontColorChangeCallback | null = null; @@ -360,6 +366,19 @@ export class FontColorPicker { this.highlightThrottle.call(); }; + private handleHighlightToggle = (): void => { + const enabled = this.highlightToggle?.checked ?? false; + if (this.highlightColorInput) { + this.highlightColorInput.disabled = !enabled; + this.highlightColorInput.style.opacity = enabled ? "1" : "0.3"; + } + if (enabled) { + this.highlightThrottle.call(); + } else if (this.onColorChange) { + this.onColorChange({ background: undefined }); + } + }; + // Flush throttle on slider release (change event) to ensure final value is applied private handleColorOpacityChange = (): void => { this.colorThrottle.flush(); @@ -369,7 +388,8 @@ export class FontColorPicker { this.setMode(mode); }; - constructor() { + constructor(options?: FontColorPickerOptions) { + this.hideGradient = options?.hideGradient ?? false; injectShotstackStyles(); } @@ -377,11 +397,21 @@ export class FontColorPicker { this.container = document.createElement("div"); this.container.className = "ss-font-color-picker"; - this.container.innerHTML = ` -
+ const tabsHTML = this.hideGradient + ? "" + : `
-
+
`; + + const gradientHTML = this.hideGradient + ? "" + : `
+ ${this.buildGradientHTML()} +
`; + + this.container.innerHTML = ` + ${tabsHTML}
@@ -397,13 +427,14 @@ export class FontColorPicker {
Highlight
- +
+ + +
-
- ${this.buildGradientHTML()} -
+ ${gradientHTML} `; parent.appendChild(this.container); @@ -423,6 +454,7 @@ export class FontColorPicker { // Query highlight elements this.highlightColorInput = this.container.querySelector("[data-highlight-color]"); + this.highlightToggle = this.container.querySelector("[data-highlight-toggle]"); // Setup event listeners using arrow function handlers for proper cleanup this.colorTab?.addEventListener("click", () => this.handleTabClick("color")); @@ -435,16 +467,19 @@ export class FontColorPicker { this.colorOpacitySlider?.addEventListener("input", this.handleColorOpacityInput); this.colorOpacitySlider?.addEventListener("change", this.handleColorOpacityChange); - // Highlight color: throttle on input + // Highlight toggle + color: throttle on input + this.highlightToggle?.addEventListener("change", this.handleHighlightToggle); this.highlightColorInput?.addEventListener("input", this.handleHighlightInputChange); // Setup gradient swatch click handlers - this.container.querySelectorAll("[data-cat]").forEach(btn => { - btn.addEventListener("click", e => { - const el = e.currentTarget as HTMLButtonElement; - this.handleGradientClick(parseInt(el.dataset["cat"] || "0", 10), parseInt(el.dataset["idx"] || "0", 10)); + if (!this.hideGradient) { + this.container.querySelectorAll("[data-cat]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLButtonElement; + this.handleGradientClick(parseInt(el.dataset["cat"] || "0", 10), parseInt(el.dataset["idx"] || "0", 10)); + }); }); - }); + } } private emitColorChange(): void { @@ -516,9 +551,15 @@ export class FontColorPicker { } } - setHighlight(color: string): void { + setHighlight(color: string | undefined): void { + const hasHighlight = color !== undefined; + if (this.highlightToggle) { + this.highlightToggle.checked = hasHighlight; + } if (this.highlightColorInput) { - this.highlightColorInput.value = color.toUpperCase(); + this.highlightColorInput.value = (color ?? "#FFFF00").toUpperCase(); + this.highlightColorInput.disabled = !hasHighlight; + this.highlightColorInput.style.opacity = hasHighlight ? "1" : "0.3"; } } @@ -535,6 +576,7 @@ export class FontColorPicker { this.colorInput?.removeEventListener("input", this.handleColorInputChange); this.colorOpacitySlider?.removeEventListener("input", this.handleColorOpacityInput); this.colorOpacitySlider?.removeEventListener("change", this.handleColorOpacityChange); + this.highlightToggle?.removeEventListener("change", this.handleHighlightToggle); this.highlightColorInput?.removeEventListener("input", this.handleHighlightInputChange); this.container?.remove(); @@ -547,6 +589,7 @@ export class FontColorPicker { this.colorOpacitySlider = null; this.colorOpacityValue = null; this.highlightColorInput = null; + this.highlightToggle = null; this.onColorChange = null; } } diff --git a/src/core/ui/font-picker.ts b/src/core/ui/font-picker.ts index 768139b..020221e 100644 --- a/src/core/ui/font-picker.ts +++ b/src/core/ui/font-picker.ts @@ -241,9 +241,9 @@ export class FontPicker { // Hide sections when searching if (this.searchQuery) { this.recentSection.style.display = "none"; - this.customSection.style.display = "none"; (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = "none"; - this.customDivider.style.display = "none"; + // Filter custom fonts by search query instead of hiding + this.updateCustomSection(this.searchQuery); } else { this.recentSection.style.display = ""; (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = ""; @@ -378,9 +378,18 @@ export class FontPicker { * Update the custom fonts section. * Shows non-Google fonts from timeline.fonts. */ - private updateCustomSection(): void { + private updateCustomSection(filterQuery?: string): void { // Get custom fonts from timeline (non-Google, non-built-in) - const customFonts = this.timelineFonts.filter(font => isCustomFont(font.src)); + let customFonts = this.timelineFonts.filter(font => isCustomFont(font.src)); + + // Filter by search query + if (filterQuery) { + const lowerQuery = filterQuery.toLowerCase().trim(); + customFonts = customFonts.filter(font => { + const displayName = this.resolveCustomFontName(font.src); + return displayName.toLowerCase().includes(lowerQuery); + }); + } // Hide section if no custom fonts if (customFonts.length === 0) { diff --git a/src/core/ui/rich-caption-toolbar.ts b/src/core/ui/rich-caption-toolbar.ts index 318d4cd..d09d22c 100644 --- a/src/core/ui/rich-caption-toolbar.ts +++ b/src/core/ui/rich-caption-toolbar.ts @@ -1,6 +1,9 @@ -import type { RichCaptionAsset, ResolvedClip } from "@schemas"; +import { findEligibleSourceClips, findCurrentSource, ensureClipAlias, UNLINKED_SOURCE } from "@core/shared/source-clip-finder"; +import type { RichCaptionAsset } from "@schemas"; +import { SpacingPanel } from "./composites/SpacingPanel"; import { StylePanel } from "./composites/StylePanel"; +import { FontColorPicker } from "./font-color-picker"; import { RichTextToolbar } from "./rich-text-toolbar"; /** @@ -13,36 +16,63 @@ export class RichCaptionToolbar extends RichTextToolbar { // Caption popup panels private wordAnimPopup: HTMLDivElement | null = null; private activeWordPopup: HTMLDivElement | null = null; + private sourcePopup: HTMLDivElement | null = null; + private sourceListContainer: HTMLDivElement | null = null; + private sourceDot: HTMLSpanElement | null = null; - // Word Animation slider refs - private wordAnimSpeedSlider: HTMLInputElement | null = null; - private wordAnimSpeedValue: HTMLSpanElement | null = null; + // Word Animation refs private wordAnimDirectionSection: HTMLDivElement | null = null; - // Active-mode scale control - private scaleSlider: HTMLInputElement | null = null; - private scaleValue: HTMLSpanElement | null = null; - - // Active-word dedicated controls + // Active-word font controls + private activeColorToggle: HTMLInputElement | null = null; private activeColorInput: HTMLInputElement | null = null; private activeOpacitySlider: HTMLInputElement | null = null; private activeOpacityValue: HTMLSpanElement | null = null; + private activeHighlightToggle: HTMLInputElement | null = null; private activeHighlightInput: HTMLInputElement | null = null; + // Active-word stroke controls + private activeStrokeToggle: HTMLInputElement | null = null; private activeStrokeWidthSlider: HTMLInputElement | null = null; private activeStrokeWidthValue: HTMLSpanElement | null = null; private activeStrokeColorInput: HTMLInputElement | null = null; private activeStrokeOpacitySlider: HTMLInputElement | null = null; private activeStrokeOpacityValue: HTMLSpanElement | null = null; - // Current slider values during drag (for final commit) - private currentWordAnimSpeed = 1; + // Active-word shadow controls + private activeShadowToggle: HTMLInputElement | null = null; + private activeShadowOffsetXSlider: HTMLInputElement | null = null; + private activeShadowOffsetXValue: HTMLSpanElement | null = null; + private activeShadowOffsetYSlider: HTMLInputElement | null = null; + private activeShadowOffsetYValue: HTMLSpanElement | null = null; + private activeShadowColorInput: HTMLInputElement | null = null; + private activeShadowOpacitySlider: HTMLInputElement | null = null; + private activeShadowOpacityValue: HTMLSpanElement | null = null; + + // Active-word text decoration buttons + private activeTextDecorationBtns: NodeListOf | null = null; + + // Active-word tab state + private activeWordTabs: NodeListOf | null = null; + private activeWordPanels: NodeListOf | null = null; + + // Active-mode scale control + private scaleSlider: HTMLInputElement | null = null; + private scaleValue: HTMLSpanElement | null = null; private currentActiveScale = 1; protected override createStylePanel(): StylePanel { return new StylePanel({}); } + protected override createSpacingPanel(): SpacingPanel { + return new SpacingPanel({ showWordSpacing: true }); + } + + protected override createFontColorPicker(): FontColorPicker { + return new FontColorPicker({ hideGradient: true }); + } + // ─── Lifecycle ───────────────────────────────────────────────────── override mount(parent: HTMLElement): void { @@ -50,7 +80,7 @@ export class RichCaptionToolbar extends RichTextToolbar { if (!this.container) return; // Hide rich-text controls irrelevant to captions - ["text-edit-toggle", "animation-toggle", "transition-toggle", "effect-toggle", "align-cycle", "anchor-top", "anchor-middle", "anchor-bottom", "underline", "linethrough"].forEach(action => { + ["text-edit-toggle", "animation-toggle", "transition-toggle", "effect-toggle"].forEach(action => { const btn = this.container!.querySelector(`[data-action="${action}"]`) as HTMLElement | null; if (!btn) return; const dropdown = btn.closest(".ss-toolbar-dropdown") as HTMLElement | null; @@ -64,20 +94,35 @@ export class RichCaptionToolbar extends RichTextToolbar { super.dispose(); this.wordAnimPopup = null; this.activeWordPopup = null; - this.wordAnimSpeedSlider = null; - this.wordAnimSpeedValue = null; this.wordAnimDirectionSection = null; - this.scaleSlider = null; - this.scaleValue = null; + this.activeColorToggle = null; this.activeColorInput = null; this.activeOpacitySlider = null; this.activeOpacityValue = null; + this.activeHighlightToggle = null; this.activeHighlightInput = null; + this.activeStrokeToggle = null; this.activeStrokeWidthSlider = null; this.activeStrokeWidthValue = null; this.activeStrokeColorInput = null; this.activeStrokeOpacitySlider = null; this.activeStrokeOpacityValue = null; + this.activeShadowToggle = null; + this.activeShadowOffsetXSlider = null; + this.activeShadowOffsetXValue = null; + this.activeShadowOffsetYSlider = null; + this.activeShadowOffsetYValue = null; + this.activeShadowColorInput = null; + this.activeShadowOpacitySlider = null; + this.activeShadowOpacityValue = null; + this.activeTextDecorationBtns = null; + this.activeWordTabs = null; + this.activeWordPanels = null; + this.scaleSlider = null; + this.scaleValue = null; + this.sourcePopup = null; + this.sourceListContainer = null; + this.sourceDot = null; } // ─── Overrides ───────────────────────────────────────────────────── @@ -90,6 +135,9 @@ export class RichCaptionToolbar extends RichTextToolbar { if (!action) return; switch (action) { + case "caption-source-toggle": + this.togglePopup(this.sourcePopup, () => this.populateSourceList()); + return; case "caption-word-anim-toggle": this.togglePopup(this.wordAnimPopup); return; @@ -104,7 +152,7 @@ export class RichCaptionToolbar extends RichTextToolbar { } protected override getPopupList(): (HTMLElement | null)[] { - return [...super.getPopupList(), this.wordAnimPopup, this.activeWordPopup]; + return [...super.getPopupList(), this.wordAnimPopup, this.activeWordPopup, this.sourcePopup]; } protected override syncState(): void { @@ -120,14 +168,6 @@ export class RichCaptionToolbar extends RichTextToolbar { this.setButtonActive(btn, btn.dataset["captionWordStyle"] === animStyle); }); - const speed = wordAnim?.speed ?? 1; - if (this.wordAnimSpeedSlider) this.wordAnimSpeedSlider.value = String(speed); - if (this.wordAnimSpeedValue) this.wordAnimSpeedValue.textContent = `${speed.toFixed(1)}x`; - - // Hide speed when "none" (nothing to animate) - const speedSection = this.container?.querySelector("[data-caption-word-speed-section]") as HTMLElement | null; - if (speedSection) speedSection.style.display = animStyle === "none" ? "none" : ""; - if (this.wordAnimDirectionSection) { this.wordAnimDirectionSection.style.display = animStyle === "slide" ? "" : "none"; } @@ -139,28 +179,101 @@ export class RichCaptionToolbar extends RichTextToolbar { // ─── Active Word Controls ─────────────────────────── const activeData = asset.active; + const baseFont = asset.font; + const baseStroke = asset.stroke; + + // Text decoration + const textDecoration = (activeData?.font as Record | undefined)?.["textDecoration"] as string | undefined; + this.activeTextDecorationBtns?.forEach(btn => { + this.setButtonActive(btn, btn.dataset["activeTextDecoration"] === (textDecoration ?? "none")); + }); - if (this.activeColorInput) this.activeColorInput.value = activeData?.font?.color ?? "#ffff00"; - const opacity = Math.round((activeData?.font?.opacity ?? 1) * 100); - if (this.activeOpacitySlider) this.activeOpacitySlider.value = String(opacity); + // ─── Font tab ────────────────────────────────────── + const hasActiveColor = activeData?.font?.color !== undefined; + if (this.activeColorToggle) this.activeColorToggle.checked = hasActiveColor; + if (this.activeColorInput) { + this.activeColorInput.value = hasActiveColor ? (activeData!.font!.color ?? "#ffff00") : (baseFont?.color ?? "#ffffff"); + this.activeColorInput.disabled = !hasActiveColor; + this.activeColorInput.style.opacity = hasActiveColor ? "1" : "0.3"; + } + const opacity = Math.round((hasActiveColor ? (activeData?.font?.opacity ?? 1) : (baseFont?.opacity ?? 1)) * 100); + if (this.activeOpacitySlider) { + this.activeOpacitySlider.value = String(opacity); + this.activeOpacitySlider.disabled = !hasActiveColor; + } if (this.activeOpacityValue) this.activeOpacityValue.textContent = `${opacity}%`; - if (this.activeHighlightInput) - this.activeHighlightInput.value = ((activeData?.font as Record | undefined)?.["background"] as string) ?? "#000000"; - - if (this.activeStrokeWidthSlider) this.activeStrokeWidthSlider.value = String(activeData?.stroke?.width ?? 0); - if (this.activeStrokeWidthValue) this.activeStrokeWidthValue.textContent = String(activeData?.stroke?.width ?? 0); - if (this.activeStrokeColorInput) this.activeStrokeColorInput.value = activeData?.stroke?.color ?? "#000000"; - const strokeOpacity = Math.round((activeData?.stroke?.opacity ?? 1) * 100); - if (this.activeStrokeOpacitySlider) this.activeStrokeOpacitySlider.value = String(strokeOpacity); + + const activeBackground = (activeData?.font as Record | undefined)?.["background"] as string | undefined; + const baseBackground = (baseFont as Record | undefined)?.["background"] as string | undefined; + const hasActiveHighlight = activeBackground !== undefined; + if (this.activeHighlightToggle) this.activeHighlightToggle.checked = hasActiveHighlight; + if (this.activeHighlightInput) { + this.activeHighlightInput.value = hasActiveHighlight ? activeBackground : (baseBackground ?? "#000000"); + this.activeHighlightInput.disabled = !hasActiveHighlight; + this.activeHighlightInput.style.opacity = hasActiveHighlight ? "1" : "0.3"; + } + + // ─── Stroke tab ──────────────────────────────────── + const activeStroke = activeData?.stroke; + const hasActiveStroke = typeof activeStroke === "object"; + if (this.activeStrokeToggle) this.activeStrokeToggle.checked = hasActiveStroke; + const strokeWidth = hasActiveStroke ? (activeStroke.width ?? 0) : (baseStroke?.width ?? 0); + if (this.activeStrokeWidthSlider) { + this.activeStrokeWidthSlider.value = String(strokeWidth); + this.activeStrokeWidthSlider.disabled = !hasActiveStroke; + } + if (this.activeStrokeWidthValue) this.activeStrokeWidthValue.textContent = String(strokeWidth); + if (this.activeStrokeColorInput) { + this.activeStrokeColorInput.value = hasActiveStroke ? (activeStroke.color ?? "#000000") : (baseStroke?.color ?? "#000000"); + this.activeStrokeColorInput.disabled = !hasActiveStroke; + this.activeStrokeColorInput.style.opacity = hasActiveStroke ? "1" : "0.3"; + } + const strokeOpacity = Math.round((hasActiveStroke ? (activeStroke.opacity ?? 1) : (baseStroke?.opacity ?? 1)) * 100); + if (this.activeStrokeOpacitySlider) { + this.activeStrokeOpacitySlider.value = String(strokeOpacity); + this.activeStrokeOpacitySlider.disabled = !hasActiveStroke; + } if (this.activeStrokeOpacityValue) this.activeStrokeOpacityValue.textContent = `${strokeOpacity}%`; + // ─── Shadow tab ──────────────────────────────────── + const activeShadow = activeData?.shadow; + const hasShadow = typeof activeShadow === "object"; + if (this.activeShadowToggle) this.activeShadowToggle.checked = hasShadow; + if (this.activeShadowOffsetXSlider) { + this.activeShadowOffsetXSlider.value = String(hasShadow ? (activeShadow.offsetX ?? 2) : 2); + this.activeShadowOffsetXSlider.disabled = !hasShadow; + } + if (this.activeShadowOffsetXValue) this.activeShadowOffsetXValue.textContent = String(hasShadow ? (activeShadow.offsetX ?? 2) : 2); + if (this.activeShadowOffsetYSlider) { + this.activeShadowOffsetYSlider.value = String(hasShadow ? (activeShadow.offsetY ?? 2) : 2); + this.activeShadowOffsetYSlider.disabled = !hasShadow; + } + if (this.activeShadowOffsetYValue) this.activeShadowOffsetYValue.textContent = String(hasShadow ? (activeShadow.offsetY ?? 2) : 2); + if (this.activeShadowColorInput) { + this.activeShadowColorInput.value = hasShadow ? (activeShadow.color ?? "#000000") : "#000000"; + this.activeShadowColorInput.disabled = !hasShadow; + this.activeShadowColorInput.style.opacity = hasShadow ? "1" : "0.3"; + } + const shadowOpacity = Math.round((hasShadow ? (activeShadow.opacity ?? 0.5) : 0.5) * 100); + if (this.activeShadowOpacitySlider) { + this.activeShadowOpacitySlider.value = String(shadowOpacity); + this.activeShadowOpacitySlider.disabled = !hasShadow; + } + if (this.activeShadowOpacityValue) this.activeShadowOpacityValue.textContent = `${shadowOpacity}%`; + + // ─── Scale ───────────────────────────────────────── const scale = activeData?.scale ?? 1; if (this.scaleSlider) this.scaleSlider.value = String(scale); if (this.scaleValue) this.scaleValue.textContent = `${scale.toFixed(1)}x`; - // Show scale section only when word animation is "pop" const scaleSection = this.container?.querySelector("[data-caption-scale-section]") as HTMLElement | null; if (scaleSection) scaleSection.style.display = animStyle === "pop" ? "" : "none"; + + // ─── Source linked indicator ────────────────────── + if (this.sourceDot) { + const currentSource = findCurrentSource(this.edit, this.selectedTrackIdx, this.selectedClipIdx); + this.sourceDot.classList.toggle("linked", !!currentSource); + } } // ─── Caption Asset Helper ────────────────────────────────────────── @@ -177,14 +290,33 @@ export class RichCaptionToolbar extends RichTextToolbar { const fragment = document.createDocumentFragment(); + // ── Source Dropdown ──────────────────────────────── + const sourceDropdown = document.createElement("div"); + sourceDropdown.className = "ss-toolbar-dropdown"; + sourceDropdown.innerHTML = ` + +
+
Caption Source
+
+
+ `; + this.sourcePopup = sourceDropdown.querySelector("[data-caption-source-popup]"); + this.sourceListContainer = sourceDropdown.querySelector("[data-source-list]"); + this.sourceDot = sourceDropdown.querySelector("[data-source-dot]"); + // ── Word Animation Group ─────────────────────────── const wordAnimDropdown = document.createElement("div"); wordAnimDropdown.className = "ss-toolbar-dropdown"; wordAnimDropdown.innerHTML = ` - +
+
Style
-
Style
@@ -196,13 +328,6 @@ export class RichCaptionToolbar extends RichTextToolbar {
-
-
Speed
-
- - 1.0x -
-