Skip to content

Commit c58f838

Browse files
authored
Merge pull request #58 from shotstack/feat/ingest-html5-steps
[1/2] Request html5 size steps for watched image uploads
2 parents 98ceda5 + 3eb9731 commit c58f838

4 files changed

Lines changed: 186 additions & 4 deletions

File tree

‎skills/shotstack/references/html5.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ FONT=$(shotstack ingest upload ./Brand.woff2 --watch --output json | tail -n1 |
119119
- **Same account and environment only.** A file ingested on `stage` works in `stage` renders, not in `v1`, and another account's files are refused. Every other remote URL is still blocked.
120120
- **10 MiB per file, 25 MiB per page.** A file counts once for every place the page references it, so reference a large image once (a CSS class, say) and reuse the class.
121121
- **Size images to the clip.** A photo much larger than the clip makes a heavier page and a slower capture. If the source has a WebP rendition named `html5-512`, `html5-1024`, `html5-2048` or `html5-4096`, the renderer uses the smallest one that covers the clip's longest edge; otherwise it uses the original.
122+
<!-- surface:cli -->
123+
- `shotstack ingest upload --watch` and `ingest fetch --watch` request those renditions for an image and wait up to 60 s for them, so the `source` URL is all the page needs.
124+
<!-- /surface -->
122125
- **A file that can't be used fails the render**, for example one that's missing, too large, or not an image or font.
123126
- **Studio's preview doesn't load ingested files yet**, so they show as missing there. The render is unaffected.
124127
<!-- surface:cli -->

‎skills/shotstack/references/ingest.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ An `html5` page can use images and fonts you ingested by putting their
8585
The file must belong to the same account and environment as the render. Limits
8686
and details are in [`html5.md`](html5.md).
8787

88+
With `--watch`, an image upload or fetch also requests smaller WebP copies
89+
(`html5-512` up to `html5-4096`, only those smaller than the image) and waits
90+
up to 60 s for them. They're listed under `outputs.renditions`; the renderer
91+
picks one for the clip size, so keep using the `source` URL.
92+
8893
## Fetch a remote URL instead of uploading
8994

9095
If the media is already at a URL but you want Shotstack to host its own copy

‎src/commands/ingest.ts‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,22 @@ import { withRecording, commandArgv } from "../recorder.ts";
99

1010
const POLL_INTERVAL_MS = 3000;
1111
const SOURCE_TERMINAL_STATES = new Set(["ready", "failed", "deleted", "overwritten"]);
12+
const RENDITION_TERMINAL_STATES = new Set(["ready", "failed", "deleted", "overwritten"]);
13+
14+
// The html5 renderer uses the smallest of these that covers the clip, or the source when it's missing.
15+
const HTML5_STEPS = [512, 1024, 2048, 4096];
16+
const STEP_WAIT_MS = 60_000;
17+
// No GIF or SVG: a WebP copy keeps only a GIF's first frame and loses an SVG's vectors.
18+
const STEP_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif", ".tif", ".tiff"]);
1219

1320
export type SourceStatus = "queued" | "importing" | "ready" | "failed" | "deleted" | "overwritten";
1421

22+
export interface RenditionAttributes {
23+
status: string;
24+
url?: string;
25+
transformation?: { size?: { width?: number; height?: number } };
26+
}
27+
1528
export interface SourceAttributes {
1629
id: string;
1730
status: SourceStatus;
@@ -21,6 +34,7 @@ export interface SourceAttributes {
2134
height?: number;
2235
duration?: number;
2336
error?: string;
37+
outputs?: { renditions?: RenditionAttributes[] };
2438
}
2539

2640
interface UploadResponse {
@@ -148,10 +162,51 @@ async function finishSource(client: Client, id: string, format: OutputFormat, wa
148162
emit(format, { id }, id);
149163
return { renderId: id, response: { id } };
150164
}
151-
const final = await pollSource(client, id, format, true);
165+
const final = await requestSteps(client, await pollSource(client, id, format, true), format);
152166
return { renderId: id, response: sourceView(final), exitCode: final.status === "failed" ? 1 : 0 };
153167
}
154168

169+
export function html5Steps(source: SourceAttributes) {
170+
const { status, width, height } = source;
171+
if (status !== "ready" || !width || !height || !source.source) return [];
172+
if (!STEP_IMAGE_EXTENSIONS.has(extname(new URL(source.source).pathname).toLowerCase())) return [];
173+
// One dimension and no fit keeps the aspect ratio.
174+
const edge = width >= height ? "width" : "height";
175+
return HTML5_STEPS.filter((step) => step < Math.max(width, height)).map((step) => ({
176+
format: "webp",
177+
size: { [edge]: step },
178+
filename: `html5-${step}`,
179+
}));
180+
}
181+
182+
export async function requestSteps(
183+
client: Client,
184+
source: SourceAttributes,
185+
format: OutputFormat,
186+
{ timeoutMs = STEP_WAIT_MS, intervalMs = POLL_INTERVAL_MS } = {},
187+
): Promise<SourceAttributes> {
188+
const steps = html5Steps(source);
189+
if (!steps.length) return source;
190+
const path = `/sources/${encodeURIComponent(source.id)}`;
191+
try {
192+
await client.post(`${path}/renditions`, { outputs: { renditions: steps } });
193+
} catch (err) {
194+
// The source renders without its steps, so the upload still succeeds.
195+
console.error(`Could not request html5 size steps: ${err instanceof Error ? err.message : String(err)}`);
196+
return source;
197+
}
198+
const deadline = Date.now() + timeoutMs;
199+
while (true) {
200+
const attributes = (await client.get<SourceResponse>(path)).data.attributes;
201+
emit(format, sourceView(attributes), formatHuman(attributes));
202+
const renditions = attributes.outputs?.renditions ?? [];
203+
const pending =
204+
renditions.length < steps.length || renditions.some((r) => !RENDITION_TERMINAL_STATES.has(r.status));
205+
if (!pending || Date.now() >= deadline) return attributes;
206+
await sleep(intervalMs);
207+
}
208+
}
209+
155210
export async function pollSource(
156211
client: Client,
157212
id: string,
@@ -231,14 +286,23 @@ function sourceView(a: SourceAttributes): SourceAttributes {
231286
if (a.height) view.height = a.height;
232287
if (a.duration) view.duration = a.duration;
233288
if (a.error) view.error = a.error;
289+
const renditions = a.outputs?.renditions;
290+
if (renditions?.length) view.outputs = { renditions: renditions.map(({ status, url }) => ({ status, url })) };
234291
return view;
235292
}
236293

237294
function formatHuman(a: SourceAttributes): string {
238295
const parts: string[] = [a.status];
239296
if (a.source) parts.push(a.source);
240297
if (a.error) parts.push(`error: ${a.error}`);
241-
return parts.join(" ");
298+
return parts.join(" ") + (a.outputs?.renditions ?? []).map(renditionLine).join("");
299+
}
300+
301+
// Ingest gives a rendition its URL only once it's ready, so until then it goes by its size.
302+
function renditionLine(r: RenditionAttributes): string {
303+
const size = r.transformation?.size;
304+
const label = r.url ?? (size ? `${size.width ?? size.height}px` : "");
305+
return `\n ${r.status} ${label}`.trimEnd();
242306
}
243307

244308
function sleep(ms: number): Promise<void> {

‎tests/ingest.test.ts‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import { describe, test, expect, afterEach, mock } from "bun:test";
2-
import { guessContentType, putToSignedUrl, pollSource, type SourceAttributes } from "../src/commands/ingest.ts";
1+
import { describe, test, expect, afterEach, mock, spyOn } from "bun:test";
2+
import {
3+
guessContentType,
4+
putToSignedUrl,
5+
pollSource,
6+
html5Steps,
7+
requestSteps,
8+
type SourceAttributes,
9+
} from "../src/commands/ingest.ts";
310
import type { Client } from "../src/http/client.ts";
411
import { ApiError } from "../src/http/client.ts";
512

@@ -102,3 +109,106 @@ describe("pollSource", () => {
102109
expect(result.status).toBe("failed");
103110
});
104111
});
112+
113+
const SRC = "https://shotstack-ingest-api-stage-sources.s3.ap-southeast-2.amazonaws.com/owner/src-1/source";
114+
115+
function readySource(ext: string, width?: number, height?: number): SourceAttributes {
116+
const source: SourceAttributes = { id: "src-1", status: "ready", source: `${SRC}.${ext}` };
117+
if (width) source.width = width;
118+
if (height) source.height = height;
119+
return source;
120+
}
121+
122+
describe("html5Steps", () => {
123+
test("sizes a landscape image's steps by width, below its long edge", () => {
124+
expect(html5Steps(readySource("jpg", 3000, 2000))).toEqual([
125+
{ format: "webp", size: { width: 512 }, filename: "html5-512" },
126+
{ format: "webp", size: { width: 1024 }, filename: "html5-1024" },
127+
{ format: "webp", size: { width: 2048 }, filename: "html5-2048" },
128+
]);
129+
});
130+
131+
test("sizes a portrait image's steps by height", () => {
132+
expect(html5Steps(readySource("PNG", 1200, 1600))).toEqual([
133+
{ format: "webp", size: { height: 512 }, filename: "html5-512" },
134+
{ format: "webp", size: { height: 1024 }, filename: "html5-1024" },
135+
]);
136+
});
137+
138+
test("never requests a step as large as the source", () => {
139+
expect(html5Steps(readySource("webp", 1024, 768)).map((s) => s.filename)).toEqual(["html5-512"]);
140+
expect(html5Steps(readySource("jpeg", 400, 300))).toEqual([]);
141+
});
142+
143+
test("skips sources that aren't raster images, aren't ready or have no size", () => {
144+
for (const ext of ["mp4", "gif", "svg", "woff2"]) expect(html5Steps(readySource(ext, 3000, 2000))).toEqual([]);
145+
expect(html5Steps({ ...readySource("jpg", 3000, 2000), status: "importing" })).toEqual([]);
146+
expect(html5Steps(readySource("jpg"))).toEqual([]);
147+
});
148+
});
149+
150+
describe("requestSteps", () => {
151+
type Step = { status: string; url: string };
152+
function stepsClient(rounds: Step[][], postError?: Error) {
153+
let i = 0;
154+
const post = mock(async () => {
155+
if (postError) throw postError;
156+
return {};
157+
});
158+
const get = mock(async () => {
159+
const renditions = rounds[Math.min(i, rounds.length - 1)]!;
160+
i += 1;
161+
return { data: { type: "source", id: "src-1", attributes: { ...readySource("jpg", 1600, 900), outputs: { renditions } } } };
162+
});
163+
return { client: { post, get } as unknown as Client, post, get };
164+
}
165+
const step = (status: string, s: number) => ({ status, url: `https://renditions.example.com/owner/src-1/html5-${s}.webp` });
166+
167+
test("requests the steps once, then waits until each one is done", async () => {
168+
const { client, post, get } = stepsClient([
169+
[step("processing", 512), step("queued", 1024)],
170+
[step("ready", 512), step("failed", 1024)],
171+
]);
172+
const result = await requestSteps(client, readySource("jpg", 1600, 900), "json", { timeoutMs: 60_000, intervalMs: 0 });
173+
174+
expect(post.mock.calls).toEqual([
175+
["/sources/src-1/renditions", { outputs: { renditions: html5Steps(readySource("jpg", 1600, 900)) } }],
176+
]);
177+
expect(get.mock.calls.length).toBe(2);
178+
expect(result.outputs?.renditions?.map((r) => r.status)).toEqual(["ready", "failed"]);
179+
});
180+
181+
test("returns the steps still processing when the wait runs out", async () => {
182+
const { client } = stepsClient([[step("processing", 512), step("processing", 1024)]]);
183+
const result = await requestSteps(client, readySource("jpg", 1600, 900), "json", { timeoutMs: 0, intervalMs: 0 });
184+
expect(result.status).toBe("ready");
185+
expect(result.outputs?.renditions?.map((r) => r.status)).toEqual(["processing", "processing"]);
186+
});
187+
188+
test("names a step by its size in text output until it has a URL", async () => {
189+
const pending = { status: "processing", transformation: { format: "webp", size: { width: 1024 } } };
190+
const { client } = stepsClient([[step("ready", 512), pending as unknown as Step]]);
191+
const lines: string[] = [];
192+
const log = spyOn(console, "log").mockImplementation((line: string) => void lines.push(line));
193+
try {
194+
await requestSteps(client, readySource("jpg", 1600, 900), "text", { timeoutMs: 0, intervalMs: 0 });
195+
} finally {
196+
log.mockRestore();
197+
}
198+
expect(lines.at(-1)).toEndWith("\n ready https://renditions.example.com/owner/src-1/html5-512.webp\n processing 1024px");
199+
});
200+
201+
test("leaves a source with no steps alone", async () => {
202+
const { client, post, get } = stepsClient([[]]);
203+
const source = readySource("mp4", 1920, 1080);
204+
expect(await requestSteps(client, source, "json", { timeoutMs: 0, intervalMs: 0 })).toBe(source);
205+
expect(post.mock.calls.length + get.mock.calls.length).toBe(0);
206+
});
207+
208+
test("returns the source when the steps can't be requested", async () => {
209+
const { client, get } = stepsClient([[]], new ApiError(400, undefined, "POST /sources/src-1/renditions → 400"));
210+
const source = readySource("jpg", 1600, 900);
211+
expect(await requestSteps(client, source, "json", { timeoutMs: 60_000, intervalMs: 0 })).toBe(source);
212+
expect(get.mock.calls.length).toBe(0);
213+
});
214+
});

0 commit comments

Comments
 (0)