diff --git a/docs/guides/export-and-share.mdx b/docs/guides/export-and-share.mdx index c6426e42705..c81875b373b 100644 --- a/docs/guides/export-and-share.mdx +++ b/docs/guides/export-and-share.mdx @@ -128,6 +128,10 @@ The command uploads the project and returns a stable URL. An anonymous first publish creates a claimable link. Sign in when the CLI should own the project immediately, update an existing link, or publish into a shared space. +A local video poster referenced with a relative path, such as +`../shared/poster.png`, is copied into the published project even when it lives +outside the project directory. + Before sharing source, remove secrets and confirm that every included media file can be shared. Tell a reviewer whether you need feedback on story, visuals, timing, facts, or final approval. diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index f9ed4172787..22b0290edfe 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, renameSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { dirname, join, posix, relative } from "node:path"; import AdmZip from "adm-zip"; const authMocks = vi.hoisted(() => ({ @@ -216,6 +224,69 @@ async function runAuthenticatedPublish( } describe("createPublishArchive", () => { + it.each(["index.html", "compositions/scene.html"])( + "includes external video posters referenced from %s", + (entryPath) => { + const projectDir = makeProjectDir(); + const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); + try { + const htmlDir = join(projectDir, dirname(entryPath)); + mkdirSync(htmlDir, { recursive: true }); + if (entryPath !== "index.html") { + writeFileSync(join(projectDir, "index.html"), ""); + } + const posterPath = stageExternalAsset(extDir, htmlDir, "poster.png", "PNG_DATA"); + const videoPath = posix.relative(posix.dirname(entryPath), "clip.mp4"); + const html = `
`; + writeFileSync(join(projectDir, entryPath), html); + writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); + + const archive = createPublishArchive(projectDir); + const zip = new AdmZip(archive.buffer); + const posterEntries = zip + .getEntries() + .filter((entry) => entry.entryName.startsWith("_ext/") && entry.name === "poster.png"); + + expect(posterEntries).toHaveLength(1); + const archivedPoster = posterEntries[0]!; + expect(archivedPoster.getData()).toEqual(Buffer.from("PNG_DATA")); + expect(zip.readAsText(entryPath)).toContain( + `poster="${posix.relative(posix.dirname(entryPath), archivedPoster.entryName)}"`, + ); + expect(zip.readAsText(entryPath)).toContain(`src="${videoPath}"`); + expect(archive.fileCount).toBe(entryPath === "index.html" ? 3 : 4); + expect(readFileSync(join(projectDir, entryPath), "utf-8")).toBe(html); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(extDir, { recursive: true, force: true }); + } + }, + ); + + it("preserves internal, remote, and inline video posters", () => { + const projectDir = makeProjectDir(); + try { + const posters = [ + "poster.png", + "https://cdn.example.com/poster.png", + "data:image/png;base64,AA==", + ]; + const html = `${posters.map((poster) => ``).join("")}`; + writeFileSync(join(projectDir, "index.html"), html); + writeFileSync(join(projectDir, "poster.png"), "PNG_DATA"); + writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); + + const archive = createPublishArchive(projectDir); + const zip = new AdmZip(archive.buffer); + + expect(archive.fileCount).toBe(3); + expect(zip.readAsText("index.html")).toBe(html); + expect(zip.getEntries().some((entry) => entry.entryName.startsWith("_ext/"))).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("packages the project and skips hidden files and node_modules", () => { const dir = makeProjectDir(); try { diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index ac39bcfb53c..9c05f5c809c 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -542,7 +542,9 @@ function localizeHtmlEntry(ctx: ExternalAssetContext, entryPath: string, content const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath)); const { document } = parseHTML(content.toString("utf-8")); const resolveTarget: HtmlAttributeResolver = (val, dir) => tryResolveExternal(ctx, val, dir); - const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, resolveTarget); + const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, resolveTarget, { + attrs: ["src", "href", "poster"], + }); const stylesChanged = rewriteStyleBlocks(document, referrerAbsDir, entryPath, resolveTarget); if (attrsChanged || stylesChanged) { ctx.fileContents.set(entryPath, Buffer.from(document.toString(), "utf-8"));