Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip: add animated images #69523

Draft
wants to merge 1 commit into
base: canary
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ const ICO = 'image/x-icon'
const TIFF = 'image/tiff'
const BMP = 'image/bmp'
const CACHE_VERSION = 3
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
const VECTOR_TYPES = [SVG]
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
const BLUR_QUALITY = 70 // should match `next-image-loader`
Expand Down Expand Up @@ -473,7 +472,10 @@ export async function optimizeImage({
height?: number
}): Promise<Buffer> {
const sharp = getSharp()
const transformer = sharp(buffer).timeout({ seconds: 7 }).rotate()
const animated = isAnimated(buffer) || undefined
const transformer = sharp(buffer, { animated })
.timeout({ seconds: 7 })
.rotate()

if (height) {
transformer.resize(width, height)
Expand Down Expand Up @@ -609,13 +611,6 @@ export async function imageOptimizer(
'"url" parameter is valid but image type is not allowed'
)
}

if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
Log.warnOnce(
`The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
)
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
}
if (VECTOR_TYPES.includes(upstreamType)) {
// We don't warn here because we already know that "dangerouslyAllowSVG"
// was enabled above, therefore the user explicitly opted in.
Expand Down
59 changes: 28 additions & 31 deletions test/integration/image-optimizer/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ type RunTestsCtx = SetupTestsCtx & {
}

const largeSize = 1080 // defaults defined in server/config.ts
const animatedWarnText =
'is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.'

export async function serveSlowImage() {
const port = await findPort()
const server = http.createServer(async (req, res) => {
Expand Down Expand Up @@ -236,72 +233,72 @@ export function runTests(ctx: RunTestsCtx) {
expect(actual).toMatch(expected)
})

it('should maintain animated gif', async () => {
it('should optimize animated gif', async () => {
const query = { w: ctx.w, q: 90, url: '/animated.gif' }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('image/gif')
expect(res.headers.get('content-type')).toContain('image/webp')
expect(res.headers.get('Cache-Control')).toBe(
`public, max-age=0, must-revalidate`
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
)
expect(res.headers.get('Vary')).toBe('Accept')
expect(res.headers.get('etag')).toBeTruthy()
expect(res.headers.get('Content-Disposition')).toBe(
`${contentDispositionType}; filename="animated.gif"`
`${contentDispositionType}; filename="animated.webp"`
)
await expectWidth(res, 50, { expectAnimated: true })
expect(ctx.nextOutput).toContain(animatedWarnText)
})

it('should maintain animated png', async () => {
it('should optimize animated png', async () => {
const query = { w: ctx.w, q: 90, url: '/animated.png' }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('image/png')
expect(res.headers.get('content-type')).toContain('image/webp')
expect(res.headers.get('Cache-Control')).toBe(
`public, max-age=0, must-revalidate`
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
)
expect(res.headers.get('Vary')).toBe('Accept')
expect(res.headers.get('etag')).toBeTruthy()
expect(res.headers.get('Content-Disposition')).toBe(
`${contentDispositionType}; filename="animated.png"`
`${contentDispositionType}; filename="animated.webp"`
)
await expectWidth(res, 100, { expectAnimated: true })
expect(ctx.nextOutput).toContain(animatedWarnText)
})

it('should maintain animated png 2', async () => {
it('should optimize animated png 2', async () => {
const query = { w: ctx.w, q: 90, url: '/animated2.png' }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('image/png')
expect(res.headers.get('content-type')).toContain('image/webp')
expect(res.headers.get('Cache-Control')).toBe(
`public, max-age=0, must-revalidate`
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
)
expect(res.headers.get('Vary')).toBe('Accept')
expect(res.headers.get('etag')).toBeTruthy()
expect(res.headers.get('Content-Disposition')).toBe(
`${contentDispositionType}; filename="animated2.png"`
`${contentDispositionType}; filename="animated2.webp"`
)
await expectWidth(res, 1105, { expectAnimated: true })
expect(ctx.nextOutput).toContain(animatedWarnText)
await expectWidth(res, ctx.w, { expectAnimated: true })
})

it('should maintain animated webp', async () => {
it('should optimize animated webp', async () => {
const query = { w: ctx.w, q: 90, url: '/animated.webp' }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('image/webp')
expect(res.headers.get('Cache-Control')).toBe(
`public, max-age=0, must-revalidate`
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
)
expect(res.headers.get('Vary')).toBe('Accept')
expect(res.headers.get('etag')).toBeTruthy()
expect(res.headers.get('Content-Disposition')).toBe(
`${contentDispositionType}; filename="animated.webp"`
)
await expectWidth(res, 400, { expectAnimated: true })
expect(ctx.nextOutput).toContain(animatedWarnText)
await expectWidth(res, ctx.w, { expectAnimated: true })
})

if (ctx.nextConfigImages?.dangerouslyAllowSVG) {
Expand Down Expand Up @@ -1246,9 +1243,9 @@ export function runTests(ctx: RunTestsCtx) {
const res1 = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
expect(res1.status).toBe(200)
expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
expect(res1.headers.get('Content-Type')).toBe('image/gif')
expect(res1.headers.get('Content-Type')).toBe('image/webp')
expect(res1.headers.get('Content-Disposition')).toBe(
`${contentDispositionType}; filename="animated.gif"`
`${contentDispositionType}; filename="animated.webp"`
)

let json1
Expand All @@ -1260,9 +1257,9 @@ export function runTests(ctx: RunTestsCtx) {
const res2 = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
expect(res2.status).toBe(200)
expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
expect(res2.headers.get('Content-Type')).toBe('image/gif')
expect(res2.headers.get('Content-Type')).toBe('image/webp')
expect(res2.headers.get('Content-Disposition')).toBe(
`${contentDispositionType}; filename="animated.gif"`
`${contentDispositionType}; filename="animated.webp"`
)
const json2 = await fsToJson(ctx.imagesDir)
expect(json2).toStrictEqual(json1)
Expand Down
Loading