1import * as stream from "@distilled.cloud/cloudflare/stream";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Stream.Watermark" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Location of the watermark image on the video.
18 */
19export type WatermarkPosition =
20 | "upperRight"
21 | "upperLeft"
22 | "lowerLeft"
23 | "lowerRight"
24 | "center";
25
26export type WatermarkProps = {
27 /**
28 * A short description of the watermark profile. If omitted, a unique
29 * name is generated from the app, stage, and logical ID. Watermark
30 * profiles have no update endpoint — changing the name triggers a
31 * replacement.
32 * @default ${app}-${stage}-${id}
33 */
34 name?: string;
35 /**
36 * URL of the watermark image (a PNG up to 2 MiB) for Cloudflare to
37 * download. Changing the URL triggers a replacement.
38 */
39 url: string;
40 /**
41 * The translucency of the image. `0.0` is completely transparent and
42 * `1.0` is completely opaque. Changing the opacity triggers a
43 * replacement.
44 * @default 1.0
45 */
46 opacity?: number;
47 /**
48 * The whitespace between the adjacent edges (determined by position)
49 * of the video and the image. `0.0` is no padding, `1.0` is a fully
50 * padded video width or length. Changing the padding triggers a
51 * replacement.
52 * @default 0.05
53 */
54 padding?: number;
55 /**
56 * The location of the image. Note that `center` ignores the `padding`
57 * parameter. Changing the position triggers a replacement.
58 * @default "upperRight"
59 */
60 position?: WatermarkPosition;
61 /**
62 * The size of the image relative to the overall size of the video.
63 * `0.0` means no scaling, `1.0` fills the entire video. Changing the
64 * scale triggers a replacement.
65 * @default 0.15
66 */
67 scale?: number;
68};
69
70export type WatermarkAttributes = {
71 /**
72 * The unique identifier for the watermark profile (Cloudflare `uid`).
73 */
74 watermarkId: string;
75 /**
76 * The Cloudflare account the watermark profile belongs to.
77 */
78 accountId: string;
79 /**
80 * A short description of the watermark profile.
81 */
82 name: string;
83 /**
84 * The date and time the watermark profile was created.
85 */
86 created: string | undefined;
87 /**
88 * The source URL the watermark image was downloaded from.
89 */
90 downloadedFrom: string | undefined;
91 /**
92 * The translucency of the image.
93 */
94 opacity: number;
95 /**
96 * The whitespace between the video edges and the image.
97 */
98 padding: number;
99 /**
100 * The location of the image.
101 */
102 position: WatermarkPosition;
103 /**
104 * The size of the image relative to the overall size of the video.
105 */
106 scale: number;
107 /**
108 * The size of the image in bytes.
109 */
110 size: number | undefined;
111 /**
112 * The height of the image in pixels.
113 */
114 height: number | undefined;
115 /**
116 * The width of the image in pixels.
117 */
118 width: number | undefined;
119};
120
121export type Watermark = Resource<
122 TypeId,
123 WatermarkProps,
124 WatermarkAttributes,
125 never,
126 Providers
127>;
128
129/**
130 * A Cloudflare Stream watermark profile — a PNG image stamped onto
131 * videos at upload time.
132 *
133 * Watermark profiles are create-only: Cloudflare exposes no update
134 * endpoint, so **every** prop change triggers a replacement (a new
135 * profile is created and the old one deleted). The image is downloaded
136 * by Cloudflare from the given URL at creation time.
137 *
138 * Requires the Stream subscription to be enabled on the account.
139 * ### Creating a watermark
140 * **Example:** Default watermark from an image URL
141 * ```typescript
142 * const watermark = yield* Cloudflare.Stream.Watermark("Logo", {
143 * url: "https://example.com/logo.png",
144 * });
145 * ```
146 *
147 * **Example:** Centered semi-transparent watermark
148 * ```typescript
149 * const watermark = yield* Cloudflare.Stream.Watermark("Logo", {
150 * url: "https://example.com/logo.png",
151 * position: "center",
152 * opacity: 0.5,
153 * scale: 0.3,
154 * });
155 * ```
156 *
157 * @see https://developers.cloudflare.com/stream/edit-videos/applying-watermarks/
158 *
159 * @resource
160 * @product Stream
161 * @category Media
162 */
163export const Watermark = Resource<Watermark>(TypeId);
164
165/**
166 * Returns true if the given value is a Watermark resource.
167 */
168export const isWatermark = (value: unknown): value is Watermark =>
169 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
170
171export const WatermarkProvider = () =>
172 Provider.succeed(Watermark, {
173 stables: [
174 "watermarkId",
175 "accountId",
176 "created",
177 "downloadedFrom",
178 "size",
179 "height",
180 "width",
181 ],
182
183 diff: Effect.fn(function* ({ id, olds, news, output }) {
184 const { accountId } = yield* yield* CloudflareEnvironment;
185 if (output !== undefined && output.accountId !== accountId) {
186 return { action: "replace" } as const;
187 }
188 if (olds === undefined) return undefined;
189 // `news` may still contain unresolved Outputs at plan time — let
190 // the engine apply the default update logic in that case.
191 if (!isResolved(news)) return undefined;
192 // No update endpoint — any prop change is a replacement. Compare
193 // against the observed output where the API echoes the value, and
194 // against olds for the create-only `url`.
195 const oldName =
196 output?.name ?? olds.name ?? (yield* watermarkName(id, olds.name));
197 const newName = yield* watermarkName(id, news.name);
198 if (
199 newName !== oldName ||
200 news.url !== olds.url ||
201 (news.opacity ?? 1.0) !== (output?.opacity ?? olds.opacity ?? 1.0) ||
202 (news.padding ?? 0.05) !== (output?.padding ?? olds.padding ?? 0.05) ||
203 (news.position ?? "upperRight") !==
204 (output?.position ?? olds.position ?? "upperRight") ||
205 (news.scale ?? 0.15) !== (output?.scale ?? olds.scale ?? 0.15)
206 ) {
207 return { action: "replace" } as const;
208 }
209 return undefined;
210 }),
211
212 read: Effect.fn(function* ({ id, output, olds }) {
213 const { accountId } = yield* yield* CloudflareEnvironment;
214 const acct = output?.accountId ?? accountId;
215
216 if (output?.watermarkId) {
217 const observed = yield* getWatermark(acct, output.watermarkId);
218 return observed ? toAttributes(observed, acct) : undefined;
219 }
220 // Cold read — recover from lost state by matching the
221 // deterministic physical name. Names are not unique on
222 // Cloudflare's side; an exact match on our generated/explicit
223 // name is the best identity we have.
224 const name = yield* watermarkName(id, olds?.name);
225 const match = yield* findByName(acct, name);
226 return match ? toAttributes(match, acct) : undefined;
227 }),
228
229 reconcile: Effect.fn(function* ({ id, news, output }) {
230 const { accountId } = yield* yield* CloudflareEnvironment;
231 const name = yield* watermarkName(id, news.name);
232
233 // Observe — the uid cached on `output` is a hint, not a
234 // guarantee: a not-found falls through to "missing" and we
235 // recreate.
236 const observed = output?.watermarkId
237 ? yield* getWatermark(output.accountId ?? accountId, output.watermarkId)
238 : undefined;
239
240 if (observed) {
241 // Create-only resource — prop drift is handled as a replacement
242 // by `diff`; nothing to sync here.
243 return toAttributes(observed, output?.accountId ?? accountId);
244 }
245
246 // Ensure — create with the full desired body. uids are
247 // auto-assigned so there is no AlreadyExists race to tolerate.
248 const created = yield* stream.createWatermark({
249 accountId,
250 name,
251 url: news.url,
252 opacity: news.opacity,
253 padding: news.padding,
254 position: news.position,
255 scale: news.scale,
256 });
257 return toAttributes(created, accountId);
258 }),
259
260 list: Effect.fn(function* () {
261 // Account-scoped collection — watermark profiles are enumerated
262 // per account. `listWatermarks` is paginated (items: "result");
263 // collect every page and hydrate into the `read` Attributes shape.
264 const { accountId } = yield* yield* CloudflareEnvironment;
265 return yield* stream.listWatermarks.pages({ accountId }).pipe(
266 Stream.runCollect,
267 Effect.map((chunk) =>
268 Array.from(chunk).flatMap((page) =>
269 (page.result ?? []).map((w) => toAttributes(w, accountId)),
270 ),
271 ),
272 );
273 }),
274
275 delete: Effect.fn(function* ({ output }) {
276 // Idempotent — a watermark already deleted out-of-band surfaces
277 // as `WatermarkNotFound` (Cloudflare error code 10003).
278 yield* stream
279 .deleteWatermark({
280 accountId: output.accountId,
281 identifier: output.watermarkId,
282 })
283 .pipe(Effect.catchTag("WatermarkNotFound", () => Effect.void));
284 }),
285 });
286
287/**
288 * Read a watermark by uid, mapping "gone" (`WatermarkNotFound`,
289 * Cloudflare error code 10003) to `undefined`.
290 */
291const getWatermark = (accountId: string, watermarkId: string) =>
292 stream
293 .getWatermark({ accountId, identifier: watermarkId })
294 .pipe(
295 Effect.catchTag("WatermarkNotFound", () => Effect.succeed(undefined)),
296 );
297
298/**
299 * Find a watermark by exact name. Names are not unique — if several
300 * watermarks carry the same name, pick the oldest for determinism.
301 */
302const findByName = (accountId: string, name: string) =>
303 stream.listWatermarks({ accountId }).pipe(
304 Effect.map((list) =>
305 [...list.result]
306 .filter((w) => w.name === name)
307 .sort((a, b) => (a.created ?? "").localeCompare(b.created ?? ""))
308 .at(0),
309 ),
310 );
311
312const watermarkName = (id: string, name: string | undefined) =>
313 Effect.gen(function* () {
314 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
315 });
316
317const toAttributes = (
318 watermark:
319 | stream.GetWatermarkResponse
320 | stream.CreateWatermarkResponse
321 | stream.ListWatermarksResponse["result"][number],
322 accountId: string,
323): WatermarkAttributes => ({
324 watermarkId: watermark.uid ?? "",
325 accountId,
326 name: watermark.name ?? "",
327 created: watermark.created ?? undefined,
328 downloadedFrom: watermark.downloadedFrom ?? undefined,
329 opacity: watermark.opacity ?? 1.0,
330 padding: watermark.padding ?? 0.05,
331 position: (watermark.position ?? "upperRight") as WatermarkPosition,
332 scale: watermark.scale ?? 0.15,
333 size: watermark.size ?? undefined,
334 height: watermark.height ?? undefined,
335 width: watermark.width ?? undefined,
336});