refactor(thumbnail): [PKG-TOOL-THUMB-04] preserve approved published defaults

This commit is contained in:
Harvey Zhao committed 2026-09-15 23:54:24 +08:00
1 parent 490e157717
commit b5f8b5e28e
37 files changed
+11172 -136

No files matched your search

+4 -2
View File
@@ -22,7 +22,9 @@ test('Monaco consumes Thumbnail global declarations and runs the actual typed co
}))
const model = api.editor.createModel(`
const input = document.createElement('input'); input.type = 'file'; document.body.appendChild(input);
const options: ArtplayerToolThumbnail.Option = { fileInput: input, number: 10 };
const mode: ArtplayerToolThumbnail.Compatibility = 'workspace-4.4';
const defaults: ArtplayerToolThumbnail.DefaultOptions = ArtplayerToolThumbnail.DEFAULTS;
const options: ArtplayerToolThumbnail.Option = { fileInput: input, number: 10, compatibility: mode, delay: defaults.delay };
const tool: ArtplayerToolThumbnail = new ArtplayerToolThumbnail(options);
let last = 0;
tool.on('custom', (value: number) => { last = value; });
@@ -32,7 +34,7 @@ const video: HTMLVideoElement = tool.video;
tool.destroy(); tool.destroy(); input.remove();
function mediaTypes(player: artplayerProxyMediabunny.MediaBunnyPlayer): number | undefined { return player.mediabunny?.currentTime; }
`, 'typescript', api.Uri.parse('file:///thumbnail-consumer.ts'))
const invalid = api.editor.createModel("new ArtplayerToolThumbnail({ fileInput: '#input' });", 'typescript', api.Uri.parse('file:///thumbnail-invalid.ts'))
const invalid = api.editor.createModel('new ArtplayerToolThumbnail({ fileInput: \'#input\' });', 'typescript', api.Uri.parse('file:///thumbnail-invalid.ts'))
try {
const worker = await (await types.getTypeScriptWorker())(model.uri, invalid.uri)
const syntax = await worker.getSyntacticDiagnostics(model.uri.toString())
+1 -1
View File
@@ -12,7 +12,7 @@ test('Thumbnail input ownership replaces real DOM listeners and generated inputs
await page.addScriptTag({ content: implementation.code })
await page.evaluate(() => {
window.inputEvents = []
window.inputTool = new window.ArtplayerToolThumbnail({ fileInput: document.querySelector('#first') })
window.inputTool = new window.ArtplayerToolThumbnail({ fileInput: document.querySelector('#first'), compatibility: 'workspace-4.4' })
window.firstInput = window.inputTool.option.fileInput
window.inputTool.on('file', file => window.inputEvents.push(file.name))
window.inputTool.setup({ fileInput: document.querySelector('#first') })
+22 -7
View File
@@ -27,7 +27,12 @@ async function capabilityControl(page, testInfo, lifecycle) {
return true
}
const implementations = [...thumbnailHistorical(), { ...await thumbnailCandidate(), inputOwnership: true, lifecycle: true }]
const candidate = await thumbnailCandidate()
const implementations = [
...thumbnailHistorical(),
{ ...candidate, inputOwnership: true, lifecycle: true, publishedPolicy: true },
{ ...candidate, name: `${candidate.name}-workspace-policy`, inputOwnership: true, lifecycle: true, compatibility: 'workspace-4.4' },
]
for (const implementation of implementations) {
test.describe(`Thumbnail tool ${implementation.name}`, () => {
test.beforeEach(async ({ page }, testInfo) => {
@@ -61,7 +66,7 @@ for (const implementation of implementations) {
}
URL.revokeObjectURL(blobUrl)
})
await page.evaluate(() => {
await page.evaluate((compatibility) => {
window.created = []
window.revoked = []
const create = URL.createObjectURL.bind(URL)
@@ -75,11 +80,11 @@ for (const implementation of implementations) {
revoked.push(url)
revoke(url)
}
window.tool = new ArtplayerToolThumbnail({ fileInput: document.querySelector('#input'), number: 10, width: 80, height: 30, column: 3, begin: 0, end: 20 })
window.tool = new ArtplayerToolThumbnail({ fileInput: document.querySelector('#input'), number: 10, width: 80, height: 30, column: 3, begin: 0, end: 20, compatibility })
window.observed = []
for (const event of ['file', 'video', 'canvas', 'update', 'done', 'error', 'destroy'])
tool.on(event, (...args) => observed.push({ event, progress: event === 'update' ? args[1] : null, processing: tool.processing }))
})
tool.on(event, (...args) => observed.push({ event, at: performance.now(), progress: event === 'update' ? args[1] : null, processing: tool.processing }))
}, implementation.compatibility)
await testInfo.attach('thumbnail-runtime', { contentType: 'application/json', body: JSON.stringify({ name: implementation.name, sha256: hash(implementation.code), recoveredIndividualFile: implementation.legacy, originalNpmArchive: false, media: path.relative(process.cwd(), sample) }) })
})
@@ -117,12 +122,20 @@ for (const implementation of implementations) {
return { width: image.width, height: image.height, sourceWidth, sourceHeight, cellHeight: tool.option.height, mime: blob.type, bytes: blob.size, nonBlack, initialEvents, events: observed, first, second, created, revoked, connected: tool.video.isConnected, retiredReadyState: tool.video.readyState, retiredSrc: tool.video.getAttribute('src') }
})
expect(result.width).toBe(240)
expect(result.cellHeight).toBe(implementation.legacy ? 30 : result.sourceHeight / result.sourceWidth * 80)
expect(result.cellHeight).toBe(implementation.legacy || implementation.publishedPolicy ? 30 : result.sourceHeight / result.sourceWidth * 80)
expect(result.height).toBe(Math.trunc(result.cellHeight * 4 + 30))
expect(result.mime).toBe('image/png')
expect(result.bytes).toBeGreaterThan(100)
expect(result.nonBlack).toBeGreaterThan(100)
expect(result.initialEvents.filter(item => item.event === 'update').map(item => item.progress)).toEqual(Array.from({ length: 10 }, (_, i) => (i + 1) / 10))
if (implementation.publishedPolicy) {
const events = result.initialEvents
expect(events.find(item => item.event === 'video').at - events.find(item => item.event === 'file').at).toBeGreaterThanOrEqual(298)
const frames = events.filter(item => item.event === 'canvas' || item.event === 'update')
for (let index = 1; index < frames.length; index++)
expect(frames[index].at - frames[index - 1].at).toBeGreaterThanOrEqual(298)
expect(events.find(item => item.event === 'done').at - frames.at(-1).at).toBeGreaterThanOrEqual(598)
}
expect(result.events.filter(item => item.event === 'done')).toHaveLength(2)
expect(result.first).not.toBe(result.second)
expect([...result.created].sort()).toEqual([...result.revoked].sort())
@@ -177,7 +190,7 @@ for (const implementation of implementations) {
expect(result.revoked).toEqual(implementation.lifecycle ? result.created : [result.created[1]])
expect(result.revoked.includes(failed.first)).toBe(!!implementation.lifecycle)
expect(result.repeated).toBe(implementation.inputOwnership ? undefined : 'NotFoundError')
expect(result.value === '').toBe(!implementation.legacy)
expect(result.value === '').toBe(!(implementation.legacy || implementation.publishedPolicy))
await testInfo.attach('thumbnail-media-failure', { contentType: 'application/json', body: JSON.stringify({ failed, result }) })
})
@@ -255,6 +268,8 @@ for (const implementation of implementations) {
})
})
await expect.poll(() => page.evaluate(() => typeof window.releaseFirst)).toBe('function')
// Published policy retains the value; selecting the same file need not fire change.
await page.locator('#input').setInputFiles([])
await page.locator('#input').setInputFiles(sample)
const result = await page.evaluate(async () => {
const source = tool.videoUrl
+2 -2
View File
@@ -7,7 +7,7 @@ const implementation = await thumbnailCandidate()
function setup(wrapper = false) {
const env = thumbnailEnvironment(implementation)
const target = new env.Element(wrapper ? 'div' : 'input')
const instance = new env.Factory({ fileInput: target, number: 10, width: 20, height: 30, column: 3 })
const instance = new env.Factory({ fileInput: target, number: 10, width: 20, height: 30, column: 3, compatibility: 'workspace-4.4' })
return { ...env, target, instance }
}
@@ -17,7 +17,7 @@ test('Thumbnail input ownership preserves class methods, constructor fields and
assert.equal(exported.default, undefined)
assert.deepEqual(Object.keys(instance), ['processing', 'option', 'video', 'duration', 'inputChange', 'ondrop'])
assert.deepEqual(Object.getOwnPropertyNames(Factory.prototype), ['constructor', 'ondrop', 'setup', 'inputChange', 'loadVideo', 'start', 'creatScreenshotDate', 'creatCanvas', 'download', 'errorHandle', 'destroy'])
assert.deepEqual(Object.keys(Factory.DEFAULTS), ['number', 'width', 'height', 'column', 'begin', 'end'])
assert.deepEqual(Object.keys(Factory.DEFAULTS), ['delay', 'number', 'width', 'height', 'column', 'begin', 'end'])
assert.equal(instance.setup({ custom: 7 }), instance)
assert.equal(instance.option.custom, 7)
instance.destroy()
+3 -2
View File
@@ -16,7 +16,8 @@ function observe(promise) {
}
function setup(ready = true) {
const env = thumbnailEnvironment(implementation)
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, width: 40, column: 3 })
// These lifecycle fixtures retain the explicitly selected workspace timing.
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, width: 40, column: 3, compatibility: 'workspace-4.4' })
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
if (ready) {
env.instance.video.duration = 100
@@ -409,7 +410,7 @@ test('Thumbnail URL creation reentrant load preserves the newer source and revok
test('Thumbnail start before the first file retains its legacy wait-for-selection behavior', async () => {
const env = thumbnailEnvironment(implementation)
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10 })
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, compatibility: 'workspace-4.4' })
const state = observe(env.instance.start())
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
env.instance.video.duration = 100
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Approved runtime policy compatibility.
import test from 'node:test'
import { thumbnailCandidate, thumbnailEnvironment, thumbnailHistorical } from './helpers/thumbnail.js'
const candidate = await thumbnailCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
function create(option = {}) {
const env = thumbnailEnvironment(candidate)
env.input = new env.Element('input')
env.tool = new env.Factory({ fileInput: env.input, number: 10, width: 40, height: 25, ...option })
return env
}
function tick(env, delay) {
const selected = [...env.timers].find(([, timer]) => Object.is(timer.delay, delay))
assert(selected, `Expected owned timer for ${delay} ms`)
env.timers.delete(selected[0])
selected[1].callback()
}
function ready(env) {
env.tool.video.duration = 100
env.tool.video.readyState = 2
env.tool.video.videoWidth = 160
env.tool.video.videoHeight = 90
env.tool.loadVideo({ name: 'sample.mp4', type: 'video/mp4' })
for (const fn of env.tool.video.handlers.get('loadedmetadata'))
fn()
}
test('Thumbnail root defaults match the recovered published profile, including key order', () => {
const actual = create()
const old = thumbnailEnvironment(thumbnailHistorical().find(item => item.legacy))
assert.deepEqual(Object.keys(actual.Factory.DEFAULTS), Object.keys(old.Factory.DEFAULTS))
for (const key of Object.keys(old.Factory.DEFAULTS))
assert.equal(actual.Factory.DEFAULTS[key], old.Factory.DEFAULTS[key])
actual.tool.destroy()
})
test('Thumbnail published policy validates and clamps delay/height while workspace preserves its inputs', () => {
const published = create({ height: 2, delay: 4000 })
assert.equal(published.tool.option.height, 10)
assert.equal(published.tool.option.delay, 1000)
assert.throws(() => published.tool.setup({ delay: 'bad' }), /delay.*number/)
assert.throws(() => published.tool.setup({ height: 'bad' }), /height.*number/)
assert.throws(() => published.tool.setup({ compatibility: 'unknown' }), /compatibility/)
const workspace = create({ compatibility: 'workspace-4.4', height: 2, delay: 4000 })
assert.equal(workspace.tool.option.height, 2)
assert.equal(workspace.tool.option.delay, 4000)
published.tool.destroy()
workspace.tool.destroy()
})
for (const compatibility of [undefined, 'published-3.5', 'workspace-4.4']) {
test(`Thumbnail ${compatibility || 'default'} preserves file/video timing and input reset policy`, () => {
const env = create({ compatibility, delay: 25 })
const seen = []
env.input.files = [{ name: 'sample.mp4', type: 'video/mp4' }]
env.input.value = 'selected'
env.tool.on('file', () => seen.push(['file', env.tool.video.src]))
env.tool.on('video', video => seen.push(['video', video.src]))
env.tool.inputChange({ target: env.input })
const workspace = compatibility === 'workspace-4.4'
assert.deepEqual(seen, workspace ? [['file', undefined], ['video', env.tool.videoUrl]] : [['file', undefined]])
assert.equal(env.input.value, workspace ? '' : 'selected')
if (!workspace) {
tick(env, 25)
assert.deepEqual(seen[1], ['video', env.tool.videoUrl])
}
env.tool.destroy()
assert.equal(env.timers.size, 0)
})
}
test('Thumbnail default extraction keeps configured height and requires delay plus frame readiness', async () => {
const env = create({ delay: 25 })
ready(env)
tick(env, 25)
env.tool.video.seeking = true
const work = env.tool.start()
await flush()
assert.equal(env.tool.option.height, 25)
tick(env, 25)
assert.equal(env.operations.filter(op => op.name === 'drawImage').length, 0)
env.tool.video.seeking = false
env.tool.video.oncanplay()
await flush()
assert.equal(env.operations.filter(op => op.name === 'drawImage').length, 1)
env.tool.destroy()
await assert.rejects(work, { name: 'AbortError' })
assert.equal(env.timers.size, 0)
})
test('Thumbnail delayed video notifications are cancelled by replacement, failure and destroy', () => {
for (const action of ['replace', 'error', 'destroy']) {
const env = create({ delay: 25 })
const seen = []
env.tool.on('video', () => seen.push(env.tool.file.name)).on('error', () => {})
env.tool.loadVideo({ name: 'old.mp4', type: 'video/mp4' })
const stale = [...env.timers.values()].find(timer => timer.delay === 25)
assert(stale)
if (action === 'replace') {
env.tool.loadVideo({ name: 'new.mp4', type: 'video/mp4' })
}
else if (action === 'error') {
for (const fn of env.tool.video.handlers.get('error'))
fn()
}
else {
env.tool.destroy()
}
stale.callback()
assert.deepEqual(seen, [])
if (action === 'replace') {
tick(env, 25)
assert.deepEqual(seen, ['new.mp4'])
}
env.tool.destroy()
assert.equal(env.timers.size, 0)
}
})
test('Thumbnail numeric NaN delay keeps the historical asynchronous timer boundary', () => {
const env = create({ delay: Number.NaN })
let events = 0
env.tool.on('video', () => events++)
env.tool.loadVideo({ name: 'sample.mp4', type: 'video/mp4' })
assert.equal(events, 0)
tick(env, Number.NaN)
assert.equal(events, 1)
env.tool.destroy()
})
for (const finish of ['complete', 'destroy', 'replace', 'error']) {
test(`Thumbnail default final delay owns completion and ${finish} settlement`, async () => {
const env = create({ delay: 25 })
ready(env)
tick(env, 25)
let done = 0
const updates = []
env.tool.on('done', () => done++).on('update', (_, progress) => updates.push(progress))
const work = env.tool.start()
await flush()
for (let frame = 0; frame < 10; frame++) {
tick(env, 25)
await flush()
}
assert.deepEqual(updates, Array.from({ length: 10 }, (_, index) => (index + 1) / 10))
assert.equal(done, 0)
assert.equal(env.tool.processing, true)
const final = [...env.timers.values()].find(timer => timer.delay === 50)
assert(final)
if (finish === 'complete') {
tick(env, 50)
await work
assert.equal(done, 1)
}
else {
if (finish === 'destroy') {
env.tool.destroy()
}
else if (finish === 'replace') {
env.tool.loadVideo({ name: 'next.mp4', type: 'video/mp4' })
}
else {
env.tool.on('error', () => {})
for (const fn of [...env.tool.video.handlers.get('error')])
fn()
}
await assert.rejects(work, finish === 'error' ? /Unable to load video/ : { name: 'AbortError' })
final.callback()
assert.equal(done, 0)
}
assert.equal(env.tool.processing, false)
env.tool.destroy()
assert.equal(env.timers.size, 0)
})
}
test('Thumbnail explicit workspace extraction retains aspect height with no policy timers', async () => {
const env = create({ compatibility: 'workspace-4.4', delay: 25 })
ready(env)
const work = env.tool.start()
await work
assert.equal(env.tool.option.height, 22.5)
assert.equal(env.operations.filter(op => op.name === 'drawImage').length, 10)
assert.equal(env.timers.size, 0)
env.tool.destroy()
})
+5 -4
View File
@@ -6,9 +6,9 @@ import vm from 'node:vm'
import { thumbnailCandidate, thumbnailEnvironment, thumbnailHistorical } from './helpers/thumbnail.js'
const candidate = await thumbnailCandidate()
function create(implementation) {
function create(implementation, option = {}) {
const env = thumbnailEnvironment(implementation)
return { ...env, tool: new env.Factory({ fileInput: new env.Element('input') }) }
return { ...env, tool: new env.Factory({ fileInput: new env.Element('input'), ...option }) }
}
const referenceModule = { exports: {} }
vm.runInNewContext(fs.readFileSync(new URL('../refactor/baselines/thumbnail-vendor/tiny-emitter-2.1.0.txt', import.meta.url), 'utf8'), { module: referenceModule })
@@ -39,7 +39,8 @@ test('Thumbnail typed facade preserves descriptors, lazy fields, default class a
assert.equal(tool.option.custom, metadata)
assert.equal(tool.option.width, 21.5)
assert.notEqual(Factory.DEFAULTS, Factory.DEFAULTS)
assert.deepEqual(Object.keys(Factory.DEFAULTS), Object.keys(baseline.Factory.DEFAULTS))
const published = thumbnailEnvironment(thumbnailHistorical().find(item => item.legacy))
assert.deepEqual(Object.keys(Factory.DEFAULTS), Object.keys(published.Factory.DEFAULTS))
assert(Number.isNaN(Factory.DEFAULTS.end))
tool.destroy()
})
@@ -132,7 +133,7 @@ test('Thumbnail typed facade retains synchronous validation, option mutation and
test('Thumbnail error event preserves arbitrary thrown message values and Promise rejection identity', async () => {
for (const failure of [{ message: 7 }, { message: { detail: 'failure' } }, 3, null]) {
const { tool } = create(candidate)
const { tool } = create(candidate, { compatibility: 'workspace-4.4' })
tool.setup({ number: 10 })
tool.file = { name: 'sample.mp4' }
tool.video.duration = 100
+12 -2
View File
@@ -1,4 +1,4 @@
import type { EventArgs, Option, ScreenshotPoint } from 'artplayer-tool-thumbnail'
import type { Compatibility, DefaultOptions, EventArgs, Option, ScreenshotPoint } from 'artplayer-tool-thumbnail'
import Thumbnail from 'artplayer-tool-thumbnail'
import Legacy from 'artplayer-tool-thumbnail/legacy'
@@ -32,8 +32,14 @@ tool.off('file').setup({ width: 100 }).download()
tool.loadVideo(input.files?.[0])
tool.loadVideo(null)
const defaults: Thumbnail.SheetOptions = Thumbnail.DEFAULTS
const publishedDefaults: DefaultOptions = Thumbnail.DEFAULTS
const delay: number = publishedDefaults.delay
const mode: Compatibility = 'workspace-4.4'
tool.setup({ compatibility: mode, delay: undefined })
legacy.setup({ compatibility: 'published-3.5', delay: 300, height: 25 })
const sheet: Thumbnail.SheetOptions = { number: 10, width: 80, height: 25, column: 3, begin: 0, end: 20 }
const end: void = tool.destroy()
void [legacy, duration, file, url, pending, canvas, points, extension, defaults, end]
void [legacy, duration, file, url, pending, canvas, points, extension, defaults, delay, sheet, end]
// @ts-expect-error Numeric width is required.
tool.setup({ width: '80' })
@@ -55,4 +61,8 @@ const earlyDensity: number = tool.density
tool.on('error', (message: string) => message.length)
// @ts-expect-error Built-in done does not carry a file.
tool.emit('done', input.files?.[0])
// @ts-expect-error Compatibility accepts only the two supported policies.
tool.setup({ compatibility: 'latest' })
// @ts-expect-error Published delay is a numeric option.
tool.setup({ delay: '300' })
void [wrongInput, wrongResult, WrongAlias, earlyDensity]