diff --git a/e2e/auto-edit-desktop.spec.js b/e2e/auto-edit-desktop.spec.js
new file mode 100644
index 0000000..22bac5d
--- /dev/null
+++ b/e2e/auto-edit-desktop.spec.js
@@ -0,0 +1,72 @@
+import { expect, test } from "@playwright/test";
+
+const videos = [process.env.AUTO_EDIT_VIDEO_A, process.env.AUTO_EDIT_VIDEO_B].filter(Boolean);
+
+test.describe("Auto Edit desktop video smoke", () => {
+ test.skip(videos.length !== 2, "Set AUTO_EDIT_VIDEO_A and AUTO_EDIT_VIDEO_B to run the local smoke test");
+
+ for (const [index, videoPath] of videos.entries()) {
+ test(`imports and samples desktop video ${index + 1}`, async ({ page }) => {
+ if (process.env.AUTO_EDIT_FULL && index === 0) test.setTimeout(12 * 60 * 1000);
+ if (process.env.AUTO_EDIT_MOCK_MODEL) await page.addInitScript(() => {
+ window.LanguageModel = {
+ availability: async () => "available",
+ create: async () => ({
+ prompt: async (messages) => {
+ const prompt = messages?.[0]?.content?.find?.((item) => item.type === "text")?.value || "";
+ const firstTime = Number(prompt.match(/timestamps?:\s*([\d.]+)/i)?.[1] || prompt.match(/at\s+([\d.]+)\s+seconds/i)?.[1] || 0);
+ return JSON.stringify({ captions: [{ start: firstTime, end: firstTime + 2.8, text: `Visual description for clip at ${firstTime.toFixed(1)} seconds.` }] });
+ },
+ destroy() {},
+ }),
+ };
+ });
+ await page.goto("/");
+ const languageIntro = page.locator(".language-intro");
+ if (await languageIntro.isVisible()) {
+ await languageIntro.locator(".language-grid button").filter({ hasText: "简体中文" }).click();
+ await expect(languageIntro).toBeHidden();
+ }
+ const mediaInput = page.locator('input[type="file"][accept*="video/mp4"]');
+ if (process.env.AUTO_EDIT_MULTI) {
+ await mediaInput.setInputFiles(videos[0]);
+ await expect(page.locator(".asset-row-button")).toHaveCount(1);
+ await mediaInput.setInputFiles(videos[1]);
+ await expect(page.locator(".asset-row-button")).toHaveCount(2);
+ } else {
+ await mediaInput.setInputFiles(videoPath);
+ }
+ const preview = page.locator(".preview-video");
+ await expect(preview).toBeVisible();
+ await expect.poll(async () => Number(await preview.evaluate((video) => video.duration))).toBeGreaterThan(0);
+ const result = await preview.evaluate(async (video) => {
+ const { extractAutoEditFrames, probeBuiltInAI } = await import("/src/lib/autoEdit.js");
+ const duration = video.duration;
+ const frames = await extractAutoEditFrames([{ id: "desktop-smoke", type: "video", src: video.src, duration, sourceDuration: duration }]);
+ return { duration, frameCount: frames.length, times: frames.map((frame) => Number(frame.time.toFixed(2))), support: await probeBuiltInAI("zh") };
+ });
+ console.log(`AUTO_EDIT_RESULT_${index + 1}=${JSON.stringify(result)}`);
+ expect(result.frameCount).toBeGreaterThanOrEqual(2);
+ expect(result.times[0]).toBe(0);
+ expect(result.times.at(-1)).toBeCloseTo(result.duration, 1);
+
+ if (process.env.AUTO_EDIT_FULL && index === 0) {
+ await page.getByRole("button", { name: "智能", exact: true }).click();
+ await page.getByRole("button", { name: "检测浏览器支持", exact: true }).click();
+ await expect(page.locator(".auto-edit-availability")).toHaveText(/可用|模型待下载|下载中/);
+ await expect(page.locator(".toast")).toBeHidden({ timeout: 10_000 });
+ await page.locator(".auto-edit-generate").click();
+ await expect(page.getByRole("dialog", { name: "画面分析与字幕检查" })).toBeVisible();
+ await page.waitForTimeout(3_000);
+ console.log(`AUTO_EDIT_UI=${JSON.stringify(await page.locator(".auto-edit-panel").innerText())}`);
+ if (await page.locator(".toast").isVisible()) console.log(`AUTO_EDIT_TOAST=${JSON.stringify(await page.locator(".toast").innerText())}`);
+ await expect(page.getByText("字幕草稿已准备好", { exact: true })).toBeVisible({ timeout: 10 * 60 * 1000 });
+ if (process.env.AUTO_EDIT_MULTI) expect(await page.locator(".auto-edit-clip-result").count()).toBe(2);
+ if (process.env.AUTO_EDIT_MOCK_MODEL) await page.screenshot({ path: "test-results/auto-edit-review.png", fullPage: true });
+ await page.getByRole("button", { name: "应用到字幕轨", exact: true }).click();
+ await expect(page.getByText("画面字幕已写入时间轴", { exact: true })).toBeVisible();
+ console.log(`AUTO_EDIT_CAPTIONS=${await page.locator(".caption-segment").count()}`);
+ }
+ });
+ }
+});
diff --git a/e2e/offline-export.spec.js b/e2e/offline-export.spec.js
new file mode 100644
index 0000000..a1eefe4
--- /dev/null
+++ b/e2e/offline-export.spec.js
@@ -0,0 +1,131 @@
+import { expect, test } from "@playwright/test";
+
+test("offline export preserves stickers, captions, voice audio, dimensions and duration", async ({ page }) => {
+ test.setTimeout(120_000);
+ await page.goto("/");
+ const result = await page.evaluate(async () => {
+ const { exportOfflineVideo } = await import("/src/lib/offlineVideoExport.js");
+ const makeImage = (draw) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = 320; canvas.height = 180;
+ draw(canvas.getContext("2d"), canvas);
+ return canvas.toDataURL("image/png");
+ };
+ const background = makeImage((context, canvas) => {
+ context.fillStyle = "#c20d18"; context.fillRect(0, 0, canvas.width, canvas.height);
+ });
+ const sticker = makeImage((context) => {
+ context.clearRect(0, 0, 320, 180);
+ context.fillStyle = "#00ffff"; context.fillRect(120, 50, 80, 80);
+ });
+ const sampleRate = 48_000;
+ const frames = sampleRate;
+ const wav = new ArrayBuffer(44 + frames * 2);
+ const view = new DataView(wav);
+ const text = (offset, value) => [...value].forEach((char, index) => view.setUint8(offset + index, char.charCodeAt(0)));
+ text(0, "RIFF"); view.setUint32(4, 36 + frames * 2, true); text(8, "WAVEfmt ");
+ view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
+ view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true);
+ view.setUint16(32, 2, true); view.setUint16(34, 16, true); text(36, "data"); view.setUint32(40, frames * 2, true);
+ for (let index = 0; index < frames; index += 1) view.setInt16(44 + index * 2, Math.sin(index / sampleRate * Math.PI * 2 * 440) * 8000, true);
+ const voice = new Blob([wav], { type: "audio/wav" });
+ const exported = await exportOfflineVideo({
+ imageSrc: background, visualType: "image",
+ visualSegments: [{ id: "visual", src: background, type: "image", duration: 1 }],
+ voiceAudioSegments: [{ id: "voice", blob: voice, start: 0, duration: 1, volume: 1 }],
+ sourceAudioBlob: null, sourceAudioSegments: [], musicBlob: null,
+ text: "", captionSegments: [{ id: "caption", text: "E2E CAPTION", start: 0, end: 1 }],
+ duration: 1, ratio: { width: 16, height: 9 }, fitMode: "cover", filter: "none",
+ captionsEnabled: true, captionPosition: "bottom", captionPlacement: "bottom", captionSize: 12,
+ captionStyle: { textColor: "#ffffff", backgroundColor: "#000000", backgroundOpacity: 0.8, fontWeight: 700 },
+ captionReferenceSize: { width: 320, height: 180 },
+ sticker: null,
+ stickerSegments: [{ id: "sticker", src: sticker, start: 0, duration: 1, x: 50, y: 50, scale: 2, opacity: 1 }],
+ exportSettings: { codec: "vp9", width: 320, height: 180, frameRate: 30, videoBitsPerSecond: 3_000_000 },
+ });
+ const audioContext = new AudioContext();
+ const decodedAudio = await audioContext.decodeAudioData((await exported.blob.arrayBuffer()).slice(0));
+ await audioContext.close();
+ const url = URL.createObjectURL(exported.blob);
+ const video = document.createElement("video");
+ video.muted = true; video.src = url;
+ await new Promise((resolve, reject) => { video.onloadedmetadata = resolve; video.onerror = reject; });
+ video.currentTime = 0.5;
+ await new Promise((resolve, reject) => { video.onseeked = resolve; video.onerror = reject; });
+ const sample = document.createElement("canvas"); sample.width = 320; sample.height = 180;
+ const context = sample.getContext("2d"); context.drawImage(video, 0, 0);
+ const center = [...context.getImageData(160, 90, 1, 1).data];
+ const lower = [...context.getImageData(160, 156, 1, 1).data];
+ const metadata = {
+ width: video.videoWidth, height: video.videoHeight,
+ duration: decodedAudio.duration, hasAudio: decodedAudio.length > 0,
+ mediaDuration: video.duration, center, lower, size: exported.blob.size,
+ diagnostics: exported.diagnostics,
+ };
+ URL.revokeObjectURL(url);
+ return metadata;
+ });
+ expect(result.width).toBe(320);
+ expect(result.height).toBe(180);
+ expect(result.duration).toBeGreaterThanOrEqual(0.95);
+ expect(result.mediaDuration).toBeGreaterThanOrEqual(0.95);
+ expect(result.hasAudio).toBe(true);
+ expect(result.size).toBeGreaterThan(10_000);
+ expect(result.center[1]).toBeGreaterThan(result.center[0]);
+ expect(result.center[2]).toBeGreaterThan(result.center[0]);
+ expect(result.lower[0] + result.lower[1] + result.lower[2]).toBeGreaterThan(40);
+ expect(result.diagnostics.frameCount).toBe(30);
+});
+
+test("offline export decodes video sequentially instead of seeking every output frame", async ({ page }) => {
+ test.setTimeout(120_000);
+ await page.goto("/");
+ const result = await page.evaluate(async () => {
+ const { exportOfflineVideo } = await import("/src/lib/offlineVideoExport.js");
+ const sourceCanvas = document.createElement("canvas"); sourceCanvas.width = 160; sourceCanvas.height = 90;
+ const sourceContext = sourceCanvas.getContext("2d");
+ sourceContext.fillStyle = "#000"; sourceContext.fillRect(0, 0, 160, 90);
+ sourceContext.fillStyle = "#fff"; sourceContext.beginPath(); sourceContext.arc(80, 45, 25, 0, Math.PI * 2); sourceContext.fill();
+ const sourceImage = sourceCanvas.toDataURL("image/png");
+ const sourceExport = await exportOfflineVideo({
+ imageSrc: sourceImage, visualType: "image", visualSegments: [{ id: "image", src: sourceImage, type: "image", duration: 0.6 }],
+ voiceAudioSegments: [], sourceAudioBlob: null, sourceAudioSegments: [], musicBlob: null, text: "", captionSegments: [],
+ duration: 0.6, ratio: { width: 16, height: 9 }, fitMode: "cover", filter: "none", captionsEnabled: false,
+ captionSize: 12, captionStyle: {}, captionReferenceSize: { width: 160, height: 90 }, sticker: null, stickerSegments: [],
+ exportSettings: { codec: "vp9", width: 160, height: 90, frameRate: 24, videoBitsPerSecond: 1_000_000 },
+ });
+ const sourceBlob = sourceExport.blob;
+ const sourceUrl = URL.createObjectURL(sourceBlob);
+ const exported = await exportOfflineVideo({
+ imageSrc: sourceUrl, visualType: "video",
+ visualSegments: [{ id: "video", src: sourceUrl, blob: sourceBlob, type: "video", duration: 0.6, sourceDuration: 0.6, sourceStart: 0, playbackRate: 1 }],
+ voiceAudioSegments: [], sourceAudioBlob: null, sourceAudioSegments: [], musicBlob: null,
+ text: "", captionSegments: [], duration: 0.6, ratio: { width: 16, height: 9 },
+ fitMode: "cover", filter: "none", captionsEnabled: false, captionSize: 12,
+ captionStyle: {}, captionReferenceSize: { width: 160, height: 90 }, sticker: null, stickerSegments: [],
+ exportSettings: { codec: "vp9", width: 160, height: 90, frameRate: 24, videoBitsPerSecond: 1_000_000 },
+ });
+ const exportedUrl = URL.createObjectURL(exported.blob);
+ const video = document.createElement("video"); video.muted = true; video.src = exportedUrl;
+ await new Promise((resolve, reject) => { video.onloadedmetadata = resolve; video.onerror = reject; });
+ video.currentTime = 0.3;
+ await new Promise((resolve, reject) => { video.onseeked = resolve; video.onerror = reject; });
+ const checkCanvas = document.createElement("canvas"); checkCanvas.width = 160; checkCanvas.height = 90;
+ const checkContext = checkCanvas.getContext("2d"); checkContext.drawImage(video, 0, 0);
+ const pixels = checkContext.getImageData(0, 0, 160, 90).data;
+ let minX = 160; let maxX = 0; let minY = 90; let maxY = 0;
+ for (let y = 0; y < 90; y += 1) for (let x = 0; x < 160; x += 1) {
+ const offset = (y * 160 + x) * 4;
+ if (pixels[offset] > 180 && pixels[offset + 1] > 180 && pixels[offset + 2] > 180) {
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y);
+ }
+ }
+ URL.revokeObjectURL(sourceUrl); URL.revokeObjectURL(exportedUrl);
+ return { size: exported.blob.size, diagnostics: exported.diagnostics, shapeRatio: (maxX - minX + 1) / (maxY - minY + 1) };
+ });
+ expect(result.size).toBeGreaterThan(500);
+ expect(result.diagnostics.videoDecodeModes).toEqual(["sequential-webcodecs"]);
+ expect(result.diagnostics.frameCount).toBe(15);
+ expect(result.shapeRatio).toBeGreaterThan(0.9);
+ expect(result.shapeRatio).toBeLessThan(1.1);
+});
diff --git a/package-lock.json b/package-lock.json
index 98530cb..32e2586 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "web-player",
- "version": "0.3.2",
+ "version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web-player",
- "version": "0.3.2",
+ "version": "0.3.3",
"license": "MIT",
"dependencies": {
"@diffusionstudio/vits-web": "^1.0.3",
@@ -14,9 +14,11 @@
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@huggingface/transformers": "^3.8.1",
+ "@mediabunny/aac-encoder": "^1.50.8",
"@phosphor-icons/react": "^2.1.10",
"fflate": "^0.8.3",
"kokoro-js": "^1.2.1",
+ "mediabunny": "^1.50.8",
"onnxruntime-web": "^1.27.0",
"pinyin-pro": "^3.27.0",
"react": "19.2.0",
@@ -1617,6 +1619,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mediabunny/aac-encoder": {
+ "version": "1.50.8",
+ "resolved": "https://registry.npmmirror.com/@mediabunny/aac-encoder/-/aac-encoder-1.50.8.tgz",
+ "integrity": "sha512-A5Se/LZd6RmYq/h36lBMSEsHvsyW8d0toR7FrAwpsFYbK+DVQYf90KiBT1Aw/mzLXx8/ypIOORJnd1sVZqOvJQ==",
+ "license": "MPL-2.0",
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/Vanilagy"
+ },
+ "peerDependencies": {
+ "mediabunny": "^1.0.0"
+ }
+ },
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmmirror.com/@phosphor-icons/react/-/react-2.1.10.tgz",
@@ -2130,6 +2145,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/dom-mediacapture-transform": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmmirror.com/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
+ "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/dom-webcodecs": "*"
+ }
+ },
+ "node_modules/@types/dom-webcodecs": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmmirror.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
+ "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -3794,6 +3824,24 @@
"node": ">=10"
}
},
+ "node_modules/mediabunny": {
+ "version": "1.50.8",
+ "resolved": "https://registry.npmmirror.com/mediabunny/-/mediabunny-1.50.8.tgz",
+ "integrity": "sha512-LgykLyQzhdpo0V2yw3UXmOpj+b4JAGdpHBwsPE6kjSt8Za0d1VllD+FV7EGHBcdV4+oHUAo+yrqbVAWxNSDCPQ==",
+ "license": "MPL-2.0",
+ "workspaces": [
+ ".",
+ "packages/*"
+ ],
+ "dependencies": {
+ "@types/dom-mediacapture-transform": "^0.1.11",
+ "@types/dom-webcodecs": "0.1.13"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/Vanilagy"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
diff --git a/package.json b/package.json
index be67f49..a228862 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "web-player",
- "version": "0.3.2",
+ "version": "0.3.3",
"private": true,
"description": "A local-first browser AI video editor for voiceovers, captions, talking avatars, and multi-track timeline export.",
"license": "MIT",
@@ -33,9 +33,11 @@
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@huggingface/transformers": "^3.8.1",
+ "@mediabunny/aac-encoder": "^1.50.8",
"@phosphor-icons/react": "^2.1.10",
"fflate": "^0.8.3",
"kokoro-js": "^1.2.1",
+ "mediabunny": "^1.50.8",
"onnxruntime-web": "^1.27.0",
"pinyin-pro": "^3.27.0",
"react": "19.2.0",
diff --git a/playwright.config.js b/playwright.config.js
index 9eaec2c..9799977 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -8,6 +8,10 @@ export default defineConfig({
use: {
baseURL: "http://127.0.0.1:5173",
headless: true,
+ channel: process.env.PLAYWRIGHT_CHANNEL || undefined,
+ launchOptions: process.env.PLAYWRIGHT_BUILT_IN_AI ? {
+ ignoreDefaultArgs: ["--disable-background-networking", "--disable-component-update"],
+ } : undefined,
},
webServer: {
command: "npm run dev",
diff --git a/public/model-cache-sw.js b/public/model-cache-sw.js
index 927ba94..903bf29 100644
--- a/public/model-cache-sw.js
+++ b/public/model-cache-sw.js
@@ -66,7 +66,11 @@ async function cacheFirst(request) {
const response = await fetch(request);
if (response.ok || response.type === "opaque") {
- cache.put(request, response.clone()).catch(() => {});
+ // Cache persistence is an optimization. A full browser quota must never
+ // block the live response consumed by an inference worker.
+ cache.put(request, response.clone()).catch((error) => {
+ if (error?.name !== "QuotaExceededError") console.warn("Model cache write failed.", error);
+ });
}
return response;
}
@@ -112,6 +116,9 @@ self.addEventListener("activate", (event) => {
)
.then(() => self.clients.claim()),
);
+ // Older Transformers.js builds created a second copy of Hugging Face model
+ // assets here. The service worker is now the sole cache owner.
+ event.waitUntil(caches.delete("transformers-cache").catch(() => false));
});
self.addEventListener("fetch", (event) => {
diff --git a/src/App.jsx b/src/App.jsx
index 4629a2d..b0637b7 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -58,19 +58,7 @@ import { getImageThumbnailCount, getVisualSegmentsTotal } from "./lib/timeline.j
import { removeVisualPropertyKeyframe, updateVisualSegmentPlaybackRate, upsertVisualKeyframe, upsertVisualPropertyKeyframe } from "./lib/visualEffects.js";
import { getLinkedSourceAudioEnd, getLinkedSourceAudioSegments } from "./lib/sourceAudioSync.js";
import { getTimelineInitialContentZoom } from "./lib/timelineScale.js";
-
-function getExportDimensions(ratio, longEdge) {
- const sourceLongEdge = Math.max(ratio.width, ratio.height);
- const scale = longEdge / sourceLongEdge;
- const even = (value) => Math.max(2, Math.round(value / 2) * 2);
- return { width: even(ratio.width * scale), height: even(ratio.height * scale) };
-}
-
-function getExportBitrate(resolution, quality, frameRate) {
- const base = { 720: 5, 1080: 10, 1440: 18, 2160: 38 }[resolution] || 10;
- const qualityScale = { standard: 0.65, high: 1, ultra: 1.45 }[quality] || 1;
- return Math.round(base * qualityScale * (frameRate / 30) * 1_000_000);
-}
+import { getExportBitrate, getExportDimensions } from "./lib/exportSettings.js";
export function App() {
const [uiLanguage, setUiLanguage] = useState(() => getStoredLanguage());
@@ -670,7 +658,10 @@ export function App() {
ratioId={ratioId}
showRatioMenu={showRatioMenu}
setShowRatioMenu={setShowRatioMenu}
- setRatioId={setRatioId}
+ setRatioId={(nextRatioId) => {
+ setRatioId(nextRatioId);
+ setFitModeFromUser("contain");
+ }}
notify={notify}
isPlaying={isPlaying}
handlePlayToggle={handlePlayToggle}
diff --git a/src/components/PreviewStage.jsx b/src/components/PreviewStage.jsx
index 0512765..e255498 100644
--- a/src/components/PreviewStage.jsx
+++ b/src/components/PreviewStage.jsx
@@ -13,6 +13,7 @@ import {
import { formatTime } from "../lib/timeline.js";
import { getVisualMaskInsets, getVisualMaskSvgDataUrl, resolveVisualTransform } from "../lib/visualEffects.js";
import { resolveVisualClipAnimation } from "../lib/visualClipAnimations.js";
+import { getStickerBaseSize } from "../lib/stickerGeometry.js";
import { CaptionOverlay } from "./CaptionOverlay.jsx";
import { IconButton } from "./ui.jsx";
@@ -69,7 +70,7 @@ export function PreviewStage({
getDraggedAsset,
applyAssetToTrack,
}) {
- const visibleStickers = stickers.length ? stickers : selectedSticker?.src || selectedSticker?.text ? [selectedSticker] : [];
+ const visibleStickers = stickers;
const hasStickerOverlay = visibleStickers.some((sticker) => sticker?.src || sticker?.text);
const hasPreviewContent = Boolean(previewVisualSrc || hasStickerOverlay);
const renderedVisualSrc = previewVisualRenderSrc || previewVisualSrc;
@@ -88,6 +89,7 @@ export function PreviewStage({
const frameWidth = Math.max(1, previewFrameSize.width || 1);
const frameHeight = Math.max(1, previewFrameSize.height || 1);
const frameMinDimension = Math.min(frameWidth, frameHeight);
+ const stickerBaseSize = getStickerBaseSize({ width: frameWidth, height: frameHeight });
const circleSize = Number.isFinite(visualMask.size) ? visualMask.size : 72;
const maskWidth = visualMask.type === "circle" ? (circleSize * frameMinDimension) / frameWidth : Number.isFinite(visualMask.width) ? visualMask.width : 80;
const maskHeight = visualMask.type === "circle" ? (circleSize * frameMinDimension) / frameHeight : Number.isFinite(visualMask.height) ? visualMask.height : 80;
@@ -337,6 +339,8 @@ export function PreviewStage({
className={`sticker-overlay sticker-transform-box ${isEditable ? "is-editable" : ""}`}
onPointerDown={(event) => startStickerDrag(event, sticker)}
style={{
+ width: `${stickerBaseSize}px`,
+ height: `${stickerBaseSize}px`,
left: `${Number.isFinite(sticker.x) ? sticker.x : 82}%`,
top: `${Number.isFinite(sticker.y) ? sticker.y : 20}%`,
transform: `translate(-50%, -50%) scale(${Number.isFinite(sticker.scale) ? sticker.scale : 1}) rotate(${Number.isFinite(sticker.rotation) ? sticker.rotation : 0}deg)`,
diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx
index 7250c6a..a5b39a4 100644
--- a/src/components/Timeline.jsx
+++ b/src/components/Timeline.jsx
@@ -258,8 +258,9 @@ export function Timeline({
...audioLanes.map((_, index) => ["audio", `${t("voiceTrack")} ${index + 1}`, `audio-${index}`]),
["music", t("musicTrack")],
];
- const isRowVisible = (track, rowId = track) =>
- trackVisibility[rowId] ?? trackVisibility[track] ?? true;
+ // Visibility is track-scoped even when overlapping clips are packed into
+ // multiple visual rows. Preview, playback and export all read the track key.
+ const isRowVisible = (track) => trackVisibility[track] ?? true;
const [rulerViewport, setRulerViewport] = useState({
scrollLeft: 0,
viewportWidth: 0,
@@ -597,7 +598,7 @@ export function Timeline({
(
showTrackContextMenu(event, track)}
@@ -748,10 +749,10 @@ export function Timeline({
aria-label={`${label} ${t("visible")}`}
onClick={(event) => {
event.stopPropagation();
- toggleTrackVisibility(rowId);
+ toggleTrackVisibility(track);
}}
>
- {isRowVisible(track, rowId) ?
:
}
+ {isRowVisible(track) ?
:
}